Spaces:
Sleeping
Sleeping
Commit ·
aaa634c
0
Parent(s):
Configure AlgoSpaced for Hugging Face Spaces deployment
Browse files- .gitignore +40 -0
- .streamlit/secrets.toml.example +19 -0
- Dockerfile +36 -0
- System_Design_DSA_Learn.md +384 -0
- algospaced-ui/.gitignore +24 -0
- algospaced-ui/README.md +73 -0
- algospaced-ui/eslint.config.js +22 -0
- algospaced-ui/index.html +15 -0
- algospaced-ui/package-lock.json +0 -0
- algospaced-ui/package.json +38 -0
- algospaced-ui/public/favicon.svg +1 -0
- algospaced-ui/public/icons.svg +24 -0
- algospaced-ui/src/App.css +184 -0
- algospaced-ui/src/App.tsx +895 -0
- algospaced-ui/src/assets/react.svg +1 -0
- algospaced-ui/src/assets/vite.svg +1 -0
- algospaced-ui/src/components/Auth.tsx +186 -0
- algospaced-ui/src/components/FileExplorer.tsx +948 -0
- algospaced-ui/src/components/JourneyPath.tsx +362 -0
- algospaced-ui/src/components/MySQLPlayground.tsx +341 -0
- algospaced-ui/src/components/PythonChallenge.tsx +237 -0
- algospaced-ui/src/components/ReviewQueue.tsx +683 -0
- algospaced-ui/src/components/Sidebar.tsx +169 -0
- algospaced-ui/src/components/TerminalEmulator.tsx +370 -0
- algospaced-ui/src/components/WindowShell.tsx +203 -0
- algospaced-ui/src/index.css +147 -0
- algospaced-ui/src/main.tsx +10 -0
- algospaced-ui/src/supabaseClient.ts +6 -0
- algospaced-ui/tsconfig.app.json +25 -0
- algospaced-ui/tsconfig.json +7 -0
- algospaced-ui/tsconfig.node.json +24 -0
- algospaced-ui/vite.config.ts +31 -0
- app.py +664 -0
- db.py +308 -0
- gdrive_sync.py +276 -0
- llm_reviewer.py +225 -0
- main.py +727 -0
- requirements.txt +12 -0
- run_app.bat +20 -0
- sandbox.py +198 -0
- schema.sql +125 -0
- scratch/test_explorer_upgrades.py +232 -0
- scratch/test_sandbox_sql.py +79 -0
- sql_playground.py +268 -0
- srs_logic.py +66 -0
.gitignore
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Virtual environment
|
| 2 |
+
venv/
|
| 3 |
+
.venv/
|
| 4 |
+
env/
|
| 5 |
+
ENV/
|
| 6 |
+
|
| 7 |
+
# Python caching
|
| 8 |
+
__pycache__/
|
| 9 |
+
*.pyc
|
| 10 |
+
*.pyo
|
| 11 |
+
*.pyd
|
| 12 |
+
|
| 13 |
+
# Local SQLite databases and logs
|
| 14 |
+
/scratch/db_*.sqlite
|
| 15 |
+
sync.log
|
| 16 |
+
debug_cookies.txt
|
| 17 |
+
debug_db.txt
|
| 18 |
+
|
| 19 |
+
# Node dependencies and build files
|
| 20 |
+
node_modules/
|
| 21 |
+
algospaced-ui/node_modules/
|
| 22 |
+
algospaced-ui/dist/
|
| 23 |
+
|
| 24 |
+
# Local configuration and secrets
|
| 25 |
+
.streamlit/secrets.toml
|
| 26 |
+
.env
|
| 27 |
+
.env.local
|
| 28 |
+
.env.*.local
|
| 29 |
+
|
| 30 |
+
# Design layouts and binaries
|
| 31 |
+
stitch_algospaced_dock_layout/
|
| 32 |
+
*.png
|
| 33 |
+
*.jpg
|
| 34 |
+
*.jpeg
|
| 35 |
+
*.gif
|
| 36 |
+
*.ico
|
| 37 |
+
*.zip
|
| 38 |
+
*.tar.gz
|
| 39 |
+
*.pdf
|
| 40 |
+
|
.streamlit/secrets.toml.example
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# .streamlit/secrets.toml
|
| 2 |
+
|
| 3 |
+
SUPABASE_URL = "your_supabase_url_here"
|
| 4 |
+
SUPABASE_KEY = "your_supabase_anon_key_here"
|
| 5 |
+
GEMINI_API_KEY = "your_gemini_api_key_here"
|
| 6 |
+
GDRIVE_FOLDER_ID = "your_gdrive_folder_id_here"
|
| 7 |
+
|
| 8 |
+
# Contents of your google-credentials.json
|
| 9 |
+
[gcp_service_account]
|
| 10 |
+
type = "service_account"
|
| 11 |
+
project_id = "..."
|
| 12 |
+
private_key_id = "..."
|
| 13 |
+
private_key = "..."
|
| 14 |
+
client_email = "..."
|
| 15 |
+
client_id = "..."
|
| 16 |
+
auth_uri = "https://accounts.google.com/o/oauth2/auth"
|
| 17 |
+
token_uri = "https://oauth2.googleapis.com/token"
|
| 18 |
+
auth_provider_x509_cert_url = "https://www.googleapis.com/oauth2/v1/certs"
|
| 19 |
+
client_x509_cert_url = "..."
|
Dockerfile
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Stage 1: Build the React frontend
|
| 2 |
+
FROM node:20-alpine AS frontend-builder
|
| 3 |
+
WORKDIR /app/frontend
|
| 4 |
+
|
| 5 |
+
# Copy package files and install dependencies
|
| 6 |
+
COPY algospaced-ui/package*.json ./
|
| 7 |
+
RUN npm ci
|
| 8 |
+
|
| 9 |
+
# Copy the rest of the frontend source code
|
| 10 |
+
COPY algospaced-ui/ ./
|
| 11 |
+
|
| 12 |
+
# Run the build
|
| 13 |
+
RUN npm run build
|
| 14 |
+
|
| 15 |
+
# Stage 2: Run the FastAPI backend
|
| 16 |
+
FROM python:3.11-slim
|
| 17 |
+
WORKDIR /app
|
| 18 |
+
|
| 19 |
+
# Install system dependencies
|
| 20 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 21 |
+
build-essential \
|
| 22 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 23 |
+
|
| 24 |
+
# Install python dependencies
|
| 25 |
+
COPY requirements.txt ./
|
| 26 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 27 |
+
|
| 28 |
+
# Copy all source files
|
| 29 |
+
COPY . .
|
| 30 |
+
|
| 31 |
+
# Copy compiled frontend from Stage 1 into the backend's static directory
|
| 32 |
+
COPY --from=frontend-builder /app/frontend/dist ./algospaced-ui/dist
|
| 33 |
+
|
| 34 |
+
# Expose port (Render/Railway will set PORT env var, default to 8000)
|
| 35 |
+
ENV PORT=8000
|
| 36 |
+
CMD uvicorn main:app --host 0.0.0.0 --port $PORT
|
System_Design_DSA_Learn.md
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# System Design Document: AlgoSpaced
|
| 2 |
+
|
| 3 |
+
A cloud-native, mobile-responsive Spaced Repetition System (SRS) designed to help software engineering candidates master the LeetCode 150 patterns. Rather than rote memorization, **AlgoSpaced** optimizes the retention of algorithmic paradigms using dynamic intervals, cloud syncing, an automated LLM Code Reviewer, a gamified Daily Streak System, and an automated Google Drive Ingestion Pipeline.
|
| 4 |
+
|
| 5 |
+
## 1. System Architecture Overview
|
| 6 |
+
|
| 7 |
+
The system is designed with a lightweight, cross-platform approach to ensure seamless transition between desktop (IDE practice) and mobile (on-the-go review) views.
|
| 8 |
+
|
| 9 |
+
```
|
| 10 |
+
+--------------------------------------------------------------+
|
| 11 |
+
| Client Tier |
|
| 12 |
+
| (Mobile Browser / Desktop Browser - Streamlit App) |
|
| 13 |
+
+------------------------------+-------------------------------+
|
| 14 |
+
|
|
| 15 |
+
HTTPS (JSON / REST)
|
| 16 |
+
|
|
| 17 |
+
v
|
| 18 |
+
+------------------------------+-------------------------------+
|
| 19 |
+
| Application Tier |
|
| 20 |
+
| (Python Serverless/Streamlit) |
|
| 21 |
+
+-------+----------------------+-----------------------+-------+
|
| 22 |
+
| | |
|
| 23 |
+
| Secure API | Sync Data | Database Queries
|
| 24 |
+
| (gemini-2.5-flash) | Google Drive & Docs | (SSL/TLS Connection)
|
| 25 |
+
v v v
|
| 26 |
+
+-------+--------------------+ +--------------------+ +-------+-------+
|
| 27 |
+
| External AI Engine | | Google Workspace | | Database Tier |
|
| 28 |
+
| (Google Gemini API) | | (Drive & Docs APIs)| | (Supabase/PG) |
|
| 29 |
+
+----------------------------+ +--------------------+ +---------------+
|
| 30 |
+
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
### Components
|
| 34 |
+
|
| 35 |
+
1. **Client Tier (Frontend):** A unified, responsive web interface built using **Streamlit**. It automatically handles fluid widths and touch-friendly target sizes for mobile screens, adapting beautifully between viewports.
|
| 36 |
+
|
| 37 |
+
2. **Application Tier (Backend Logic):** Python logic running the core Spaced Repetition engine (calculating intervals), processing user code inputs, calculating daily active streaks, coordinating Google Drive folder scans, and structuring payloads for the LLM evaluation.
|
| 38 |
+
|
| 39 |
+
3. **External AI Engine (Gemini API):** An intelligent grading agent utilizing `gemini-2.5-flash-preview-09-2025` to evaluate candidate code for logical correctness, time/space complexity, and pattern edge-cases.
|
| 40 |
+
|
| 41 |
+
4. **Database Tier (Cloud Database):** A managed PostgreSQL instance (e.g., Supabase) storing user progress, problem history, review queues, and user streak statistics.
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
## 2. Spaced Repetition & Streak Algorithms
|
| 45 |
+
|
| 46 |
+
### 2.1 Leitner System Spaced Repetition
|
| 47 |
+
|
| 48 |
+
To avoid linear burnout, AlgoSpaced implements a customized **Leitner System** optimized for algorithmic patterns. Problems transition between 5 "Boxes", where each box represents a distinct revision interval.
|
| 49 |
+
|
| 50 |
+
Let the next review date $D_{next}$ for a problem $p$ be defined as:
|
| 51 |
+
|
| 52 |
+
$$D_{next} = D_{last} + I(b)$$
|
| 53 |
+
|
| 54 |
+
Where $D_{last}$ is the date of the last review, and $I(b)$ is the interval (in days) associated with the problem's current Box level $b \in \{1, 2, 3, 4, 5\}$:
|
| 55 |
+
|
| 56 |
+
$$I(b) = \begin{cases} 1 \text{ day} & \text{if } b = 1 \text{ (Forgot / Hard)} \\ 3 \text{ days} & \text{if } b = 2 \\ 7 \text{ days} & \text{if } b = 3 \\ 14 \text{ days} & \text{if } b = 4 \\ 30 \text{ days} & \text{if } b = 5 \text{ (Mastered)} \end{cases}$$
|
| 57 |
+
|
| 58 |
+
### 2.2 SRS State Transitions
|
| 59 |
+
|
| 60 |
+
- **On Correct Evaluation (Easy / AI Approved):**
|
| 61 |
+
|
| 62 |
+
$$b_{new} = \min(b_{current} + 1, 5)$$
|
| 63 |
+
- **On Mixed Evaluation (Medium / Minor Bugs):**
|
| 64 |
+
|
| 65 |
+
$$b_{new} = b_{current}$$
|
| 66 |
+
|
| 67 |
+
_(No change in Box level, but resets the countdown interval to_ $I(b_{current})$ _days from today)_
|
| 68 |
+
|
| 69 |
+
- **On Incorrect/Forgotten Evaluation (Hard):**
|
| 70 |
+
|
| 71 |
+
$$b_{new} = 1$$
|
| 72 |
+
|
| 73 |
+
_(Resets to Box 1 immediately to build retention)_
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
### 2.3 Daily Streak Mathematical Model
|
| 77 |
+
|
| 78 |
+
To keep motivation high, the system maintains a running daily streak tracking active participation. A streak increments when the user completes at least **one** practice session (submitting code or passing a review) within a calendar day.
|
| 79 |
+
|
| 80 |
+
Let $T_{now}$ be the current timestamp in the user's localized timezone, and let $Date(T)$ be the calendar date portion of a timestamp. Let $D_{last\_active}$ be the date of the user's last logged review session.
|
| 81 |
+
|
| 82 |
+
When a review session is completed, the system evaluates the state of the streak:
|
| 83 |
+
|
| 84 |
+
$$\text{Streak Status} = \begin{cases} \text{No Change} & \text{if } Date(T_{now}) = D_{last\_active} \\ \text{Increment Streak} & \text{if } Date(T_{now}) = D_{last\_active} + 1 \text{ day} \\ \text{Reset Streak to 1} & \text{if } Date(T_{now}) > D_{last\_active} + 1 \text{ day} \end{cases}$$
|
| 85 |
+
|
| 86 |
+
#### Lazy Streak Verification on Login
|
| 87 |
+
|
| 88 |
+
To ensure the visual streak updates immediately even before the user starts a session, the frontend runs a checking routine whenever the application loads:
|
| 89 |
+
|
| 90 |
+
$$\text{Current Streak} = \begin{cases} 0 & \text{if } Date(T_{now}) > D_{last\_active} + 1 \text{ day} \\ \text{Unchanged} & \text{otherwise} \end{cases}$$
|
| 91 |
+
|
| 92 |
+
## 3. Google Drive Ingestion Pipeline
|
| 93 |
+
|
| 94 |
+
To make registration friction-free, users only need to drop their LeetCode notes as Google Docs into a dedicated Google Drive folder (e.g., named `LeetCode-150-Reviews`). The system will parse and sync them automatically.
|
| 95 |
+
|
| 96 |
+
### 3.1 Authentication Workflow
|
| 97 |
+
|
| 98 |
+
The background sync worker uses a secure Google Cloud service account with specific read-only permissions:
|
| 99 |
+
|
| 100 |
+
- **Scope Required:** `https://www.googleapis.com/auth/drive.readonly` and `https://www.googleapis.com/auth/documents.readonly`
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
### 3.2 Document Structured Parsing Rules
|
| 104 |
+
|
| 105 |
+
Your notes are structured inside the Google Doc in a human-friendly format. The ingestion script parses the Google Doc's JSON structural elements (`body.content`) and applies regex matching to dynamically extract fields:
|
| 106 |
+
|
| 107 |
+
```
|
| 108 |
+
+--------------------------------------------------------------+
|
| 109 |
+
| Google Doc Layout |
|
| 110 |
+
+--------------------------------------------------------------+
|
| 111 |
+
| Document Title: [Problem Name] (e.g. "Two Sum") |
|
| 112 |
+
| |
|
| 113 |
+
| Pattern: [Pattern Name] |
|
| 114 |
+
| Complexity: Time: [O(N)], Space: [O(N)] |
|
| 115 |
+
| |
|
| 116 |
+
| --- |
|
| 117 |
+
| [Optional: Aha! Moment Line / Key insight] |
|
| 118 |
+
| |
|
| 119 |
+
| Code: |
|
| 120 |
+
| ```python |
|
| 121 |
+
| ... python code solution ... |
|
| 122 |
+
| ``` |
|
| 123 |
+
+--------------------------------------------------------------+
|
| 124 |
+
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
1. **Problem Name:** Extracted directly from the Doc filename.
|
| 128 |
+
|
| 129 |
+
2. **Pattern Name:** Found via Regex search for line: `Pattern:\s*(.*)`. Default to `Unassigned` if not found.
|
| 130 |
+
|
| 131 |
+
3. **Complexities:** Found via Regex matching: `Time:\s*(\S+)\s*,\s*Space:\s*(\S+)`.
|
| 132 |
+
|
| 133 |
+
4. **Correct Working Code Block:** Parsed by grabbing all texts following a `Code:` delimiter or inside formatted monospace blocks.
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
## 4. Data Models & Database Schema
|
| 137 |
+
|
| 138 |
+
The database must be hosted in the cloud to sync perfectly between your phone and laptop. Below is the relational schema designed for **PostgreSQL**.
|
| 139 |
+
|
| 140 |
+
### PostgreSQL Schema Initialization Script
|
| 141 |
+
|
| 142 |
+
```
|
| 143 |
+
-- Enable UUID extension
|
| 144 |
+
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
| 145 |
+
|
| 146 |
+
-- Table: problems
|
| 147 |
+
CREATE TABLE problems (
|
| 148 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 149 |
+
name VARCHAR(255) NOT NULL UNIQUE,
|
| 150 |
+
pattern VARCHAR(100) NOT NULL,
|
| 151 |
+
difficulty VARCHAR(20) NOT NULL DEFAULT 'Medium' CHECK (difficulty IN ('Easy', 'Medium', 'Hard')),
|
| 152 |
+
google_doc_url TEXT,
|
| 153 |
+
google_doc_id VARCHAR(100) UNIQUE, -- Tracks file to prevent duplicates
|
| 154 |
+
optimal_time VARCHAR(50) NOT NULL DEFAULT 'O(N)',
|
| 155 |
+
optimal_space VARCHAR(50) NOT NULL DEFAULT 'O(N)',
|
| 156 |
+
reference_code TEXT, -- Stores reference code synced from Doc
|
| 157 |
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
| 158 |
+
);
|
| 159 |
+
|
| 160 |
+
-- Table: user_reviews
|
| 161 |
+
CREATE TABLE user_reviews (
|
| 162 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 163 |
+
problem_id UUID NOT NULL REFERENCES problems(id) ON DELETE CASCADE,
|
| 164 |
+
box_level INTEGER NOT NULL DEFAULT 1 CHECK (box_level BETWEEN 1 AND 5),
|
| 165 |
+
last_reviewed TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
| 166 |
+
next_review TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
| 167 |
+
times_correct INTEGER NOT NULL DEFAULT 0,
|
| 168 |
+
total_attempts INTEGER NOT NULL DEFAULT 0,
|
| 169 |
+
CONSTRAINT unique_problem_review UNIQUE (problem_id)
|
| 170 |
+
);
|
| 171 |
+
|
| 172 |
+
-- Table: user_streaks
|
| 173 |
+
CREATE TABLE user_streaks (
|
| 174 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 175 |
+
current_streak INTEGER NOT NULL DEFAULT 0 CHECK (current_streak >= 0),
|
| 176 |
+
longest_streak INTEGER NOT NULL DEFAULT 0 CHECK (longest_streak >= 0),
|
| 177 |
+
last_active_date DATE,
|
| 178 |
+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
| 179 |
+
);
|
| 180 |
+
|
| 181 |
+
```
|
| 182 |
+
|
| 183 |
+
### Table Properties & Metadata
|
| 184 |
+
|
| 185 |
+
#### Table: `problems` (Modified for Google Sync)
|
| 186 |
+
|
| 187 |
+
Column Name
|
| 188 |
+
|
| 189 |
+
Data Type
|
| 190 |
+
|
| 191 |
+
Constraints
|
| 192 |
+
|
| 193 |
+
Description
|
| 194 |
+
|
| 195 |
+
`id`
|
| 196 |
+
|
| 197 |
+
`UUID`
|
| 198 |
+
|
| 199 |
+
Primary Key, Default: `uuid_generate_v4()`
|
| 200 |
+
|
| 201 |
+
Unique problem identifier
|
| 202 |
+
|
| 203 |
+
`name`
|
| 204 |
+
|
| 205 |
+
`VARCHAR(255)`
|
| 206 |
+
|
| 207 |
+
Not Null, Unique
|
| 208 |
+
|
| 209 |
+
LeetCode title (e.g., "3Sum")
|
| 210 |
+
|
| 211 |
+
`pattern`
|
| 212 |
+
|
| 213 |
+
`VARCHAR(100)`
|
| 214 |
+
|
| 215 |
+
Not Null
|
| 216 |
+
|
| 217 |
+
Core paradigm (e.g., "Two Pointers")
|
| 218 |
+
|
| 219 |
+
`google_doc_url`
|
| 220 |
+
|
| 221 |
+
`TEXT`
|
| 222 |
+
|
| 223 |
+
Nullable
|
| 224 |
+
|
| 225 |
+
Hyperlink to personal Google Doc notes
|
| 226 |
+
|
| 227 |
+
`google_doc_id`
|
| 228 |
+
|
| 229 |
+
`VARCHAR(100)`
|
| 230 |
+
|
| 231 |
+
Unique
|
| 232 |
+
|
| 233 |
+
Unique identifier of file in Google Drive
|
| 234 |
+
|
| 235 |
+
`reference_code`
|
| 236 |
+
|
| 237 |
+
`TEXT`
|
| 238 |
+
|
| 239 |
+
Nullable
|
| 240 |
+
|
| 241 |
+
The ideal source code extracted from your Doc
|
| 242 |
+
|
| 243 |
+
## 5. AI Code Reviewer Integration (Gemini API)
|
| 244 |
+
|
| 245 |
+
To evaluate handwritten code on a mobile device without compiler dependencies, heavy runtimes, or strict test harnesses, the system integrates the Gemini API using `gemini-2.5-flash-preview-09-2025`.
|
| 246 |
+
|
| 247 |
+
### API Payload Schema Configuration
|
| 248 |
+
|
| 249 |
+
```
|
| 250 |
+
import google.generativeai as genai
|
| 251 |
+
from google.genai import types
|
| 252 |
+
|
| 253 |
+
# Define JSON Schema to guarantee structural correctness of the AI response
|
| 254 |
+
response_schema = {
|
| 255 |
+
"type": "OBJECT",
|
| 256 |
+
"properties": {
|
| 257 |
+
"is_correct": {"type": "BOOLEAN"},
|
| 258 |
+
"detected_complexity": {
|
| 259 |
+
"type": "OBJECT",
|
| 260 |
+
"properties": {
|
| 261 |
+
"time": {"type": "STRING"},
|
| 262 |
+
"space": {"type": "STRING"}
|
| 263 |
+
},
|
| 264 |
+
"required": ["time", "space"]
|
| 265 |
+
},
|
| 266 |
+
"bugs": {
|
| 267 |
+
"type": "ARRAY",
|
| 268 |
+
"items": {"type": "STRING"}
|
| 269 |
+
},
|
| 270 |
+
"key_suggestion": {"type": "STRING"}
|
| 271 |
+
},
|
| 272 |
+
"required": ["is_correct", "detected_complexity", "bugs", "key_suggestion"]
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
```
|
| 276 |
+
|
| 277 |
+
### System & Prompt Prompts
|
| 278 |
+
|
| 279 |
+
```
|
| 280 |
+
system_instruction = """
|
| 281 |
+
You are an expert technical interviewer evaluating a candidate's LeetCode code written from memory.
|
| 282 |
+
Your primary objective is to review code correctness, algorithmic efficiency, and potential edge-case failures.
|
| 283 |
+
Be highly forgiving of minor visual/syntax typos (e.g., off-by-one indentation, minor spelling mistakes in variables, or missing colons)
|
| 284 |
+
which would be caught in 1 second by a standard IDE. Focus on raw algorithmic correctness.
|
| 285 |
+
"""
|
| 286 |
+
|
| 287 |
+
user_prompt_template = """
|
| 288 |
+
Problem Name: {problem_name}
|
| 289 |
+
Target Pattern: {pattern_name}
|
| 290 |
+
Expected Complexity: {target_complexities}
|
| 291 |
+
|
| 292 |
+
Candidate's Code Submission:
|
| 293 |
+
{candidate_code}
|
| 294 |
+
|
| 295 |
+
Evaluate the code logic and return the structured JSON output.
|
| 296 |
+
"""
|
| 297 |
+
|
| 298 |
+
```
|
| 299 |
+
|
| 300 |
+
## 6. System Execution Workflows
|
| 301 |
+
|
| 302 |
+
### Daily Queue Retrieval Workflow
|
| 303 |
+
|
| 304 |
+
The system minimizes burnout by limiting the daily workload to problems scheduled for _on or before_ the current timestamp.
|
| 305 |
+
|
| 306 |
+
```
|
| 307 |
+
[User Opens App]
|
| 308 |
+
|
|
| 309 |
+
v
|
| 310 |
+
[Query Database]
|
| 311 |
+
1. Retrieve Active Streak -> SELECT * FROM user_streaks LIMIT 1;
|
| 312 |
+
2. Lazy Check: If CURRENT_DATE > last_active_date + 1, reset current_streak to 0 in DB.
|
| 313 |
+
3. Query Daily Cards -> SELECT * FROM user_reviews
|
| 314 |
+
JOIN problems ON user_reviews.problem_id = problems.id
|
| 315 |
+
WHERE next_review <= NOW()
|
| 316 |
+
ORDER BY next_review ASC;
|
| 317 |
+
|
|
| 318 |
+
v
|
| 319 |
+
[Render Streamlit Mobile-Responsive Cards & Streak Banner]
|
| 320 |
+
|
| 321 |
+
```
|
| 322 |
+
|
| 323 |
+
### Google Drive Ingest Sync (Triggered via UI Button or Cron)
|
| 324 |
+
|
| 325 |
+
The user can tap "Sync Folder" in the app interface, executing the background sync worker:
|
| 326 |
+
|
| 327 |
+
```
|
| 328 |
+
[User Taps "Sync Google Folder"]
|
| 329 |
+
|
|
| 330 |
+
v
|
| 331 |
+
[Read Files from Folder]
|
| 332 |
+
Call Google Drive API: List files inside target FolderID
|
| 333 |
+
|
|
| 334 |
+
v
|
| 335 |
+
[Filter New Google Doc IDs]
|
| 336 |
+
Identify Doc IDs not present in problems.google_doc_id DB column
|
| 337 |
+
|
|
| 338 |
+
v
|
| 339 |
+
+------------------+------------------+
|
| 340 |
+
| |
|
| 341 |
+
[No New Docs Found] [New Docs Found]
|
| 342 |
+
| |
|
| 343 |
+
v v
|
| 344 |
+
Complete Sync Task For each new doc:
|
| 345 |
+
- Call Google Docs API
|
| 346 |
+
- Parse name, patterns, code
|
| 347 |
+
- INSERT INTO problems
|
| 348 |
+
- INSERT INTO user_reviews
|
| 349 |
+
(Default Box = 1, next_review = now())
|
| 350 |
+
|
|
| 351 |
+
v
|
| 352 |
+
Refresh Review Queue
|
| 353 |
+
|
| 354 |
+
```
|
| 355 |
+
|
| 356 |
+
## 7. Portability, Accessibility & Performance
|
| 357 |
+
|
| 358 |
+
### 1. Fully Responsive Fluid Layouts
|
| 359 |
+
|
| 360 |
+
- **Framework Adaptation:** All Streamlit elements are configured using native block components (`st.columns`, `st.container`) that transition seamlessly from a dual-pane desktop view to a single-stack layout on iOS/Android devices.
|
| 361 |
+
|
| 362 |
+
- **Typographic Scaling:** Markdown blocks use standard relative heading levels (`###` and body text) to remain highly readable on varying screen resolutions.
|
| 363 |
+
|
| 364 |
+
- **Streak Visual Integration:** The app features a high-visibility, fluid CSS indicator displaying the user's current streak count (e.g., "🔥 14 Days Active") pinned to the top of both mobile and desktop screens.
|
| 365 |
+
|
| 366 |
+
- **Touch Target Optimization:** Touch elements such as "Submit", "Forgot", and "Easy" use high-contrast primary colors with custom internal padding so they can be clicked reliably with a single thumb tap.
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
### 2. Manual Offline Fallback Mode (No Connection Option)
|
| 370 |
+
|
| 371 |
+
Traveling frequently results in spotty network connections. To prevent missed study days and protect your active streak:
|
| 372 |
+
|
| 373 |
+
- **The "Self-Grade" Toggle:** If connection to the Gemini API is lost or latency is too high, users can toggle a "Manual Grade" override.
|
| 374 |
+
|
| 375 |
+
- **Mechanism:** The app displays the user's stored Google Doc code and asks the user to manually rate themselves ("Forgot", "Struggled", "Smashed It").
|
| 376 |
+
|
| 377 |
+
- **Local Synchronization:** The review state transition calculates intervals locally, registers the streak daily increment, and pushes the DB update asynchronously, guaranteeing zero study gaps.
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
### 3. Data Protection & Sync Security
|
| 381 |
+
|
| 382 |
+
- **Encrypted Cloud Connect:** All DB calls are encrypted via TLS/SSL.
|
| 383 |
+
|
| 384 |
+
- **Personal Space Partitioning:** The Supabase endpoint utilizes Row-Level Security (RLS) to ensure that only you can view, create, or alter your saved algorithmic problem queues and personal study metrics.
|
algospaced-ui/.gitignore
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Logs
|
| 2 |
+
logs
|
| 3 |
+
*.log
|
| 4 |
+
npm-debug.log*
|
| 5 |
+
yarn-debug.log*
|
| 6 |
+
yarn-error.log*
|
| 7 |
+
pnpm-debug.log*
|
| 8 |
+
lerna-debug.log*
|
| 9 |
+
|
| 10 |
+
node_modules
|
| 11 |
+
dist
|
| 12 |
+
dist-ssr
|
| 13 |
+
*.local
|
| 14 |
+
|
| 15 |
+
# Editor directories and files
|
| 16 |
+
.vscode/*
|
| 17 |
+
!.vscode/extensions.json
|
| 18 |
+
.idea
|
| 19 |
+
.DS_Store
|
| 20 |
+
*.suo
|
| 21 |
+
*.ntvs*
|
| 22 |
+
*.njsproj
|
| 23 |
+
*.sln
|
| 24 |
+
*.sw?
|
algospaced-ui/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# React + TypeScript + Vite
|
| 2 |
+
|
| 3 |
+
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
| 4 |
+
|
| 5 |
+
Currently, two official plugins are available:
|
| 6 |
+
|
| 7 |
+
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
| 8 |
+
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
| 9 |
+
|
| 10 |
+
## React Compiler
|
| 11 |
+
|
| 12 |
+
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
| 13 |
+
|
| 14 |
+
## Expanding the ESLint configuration
|
| 15 |
+
|
| 16 |
+
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
| 17 |
+
|
| 18 |
+
```js
|
| 19 |
+
export default defineConfig([
|
| 20 |
+
globalIgnores(['dist']),
|
| 21 |
+
{
|
| 22 |
+
files: ['**/*.{ts,tsx}'],
|
| 23 |
+
extends: [
|
| 24 |
+
// Other configs...
|
| 25 |
+
|
| 26 |
+
// Remove tseslint.configs.recommended and replace with this
|
| 27 |
+
tseslint.configs.recommendedTypeChecked,
|
| 28 |
+
// Alternatively, use this for stricter rules
|
| 29 |
+
tseslint.configs.strictTypeChecked,
|
| 30 |
+
// Optionally, add this for stylistic rules
|
| 31 |
+
tseslint.configs.stylisticTypeChecked,
|
| 32 |
+
|
| 33 |
+
// Other configs...
|
| 34 |
+
],
|
| 35 |
+
languageOptions: {
|
| 36 |
+
parserOptions: {
|
| 37 |
+
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
| 38 |
+
tsconfigRootDir: import.meta.dirname,
|
| 39 |
+
},
|
| 40 |
+
// other options...
|
| 41 |
+
},
|
| 42 |
+
},
|
| 43 |
+
])
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
| 47 |
+
|
| 48 |
+
```js
|
| 49 |
+
// eslint.config.js
|
| 50 |
+
import reactX from 'eslint-plugin-react-x'
|
| 51 |
+
import reactDom from 'eslint-plugin-react-dom'
|
| 52 |
+
|
| 53 |
+
export default defineConfig([
|
| 54 |
+
globalIgnores(['dist']),
|
| 55 |
+
{
|
| 56 |
+
files: ['**/*.{ts,tsx}'],
|
| 57 |
+
extends: [
|
| 58 |
+
// Other configs...
|
| 59 |
+
// Enable lint rules for React
|
| 60 |
+
reactX.configs['recommended-typescript'],
|
| 61 |
+
// Enable lint rules for React DOM
|
| 62 |
+
reactDom.configs.recommended,
|
| 63 |
+
],
|
| 64 |
+
languageOptions: {
|
| 65 |
+
parserOptions: {
|
| 66 |
+
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
| 67 |
+
tsconfigRootDir: import.meta.dirname,
|
| 68 |
+
},
|
| 69 |
+
// other options...
|
| 70 |
+
},
|
| 71 |
+
},
|
| 72 |
+
])
|
| 73 |
+
```
|
algospaced-ui/eslint.config.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import js from '@eslint/js'
|
| 2 |
+
import globals from 'globals'
|
| 3 |
+
import reactHooks from 'eslint-plugin-react-hooks'
|
| 4 |
+
import reactRefresh from 'eslint-plugin-react-refresh'
|
| 5 |
+
import tseslint from 'typescript-eslint'
|
| 6 |
+
import { defineConfig, globalIgnores } from 'eslint/config'
|
| 7 |
+
|
| 8 |
+
export default defineConfig([
|
| 9 |
+
globalIgnores(['dist']),
|
| 10 |
+
{
|
| 11 |
+
files: ['**/*.{ts,tsx}'],
|
| 12 |
+
extends: [
|
| 13 |
+
js.configs.recommended,
|
| 14 |
+
tseslint.configs.recommended,
|
| 15 |
+
reactHooks.configs.flat.recommended,
|
| 16 |
+
reactRefresh.configs.vite,
|
| 17 |
+
],
|
| 18 |
+
languageOptions: {
|
| 19 |
+
globals: globals.browser,
|
| 20 |
+
},
|
| 21 |
+
},
|
| 22 |
+
])
|
algospaced-ui/index.html
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
| 7 |
+
<title>AlgoSpaced - Arcade Review</title>
|
| 8 |
+
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,700;12..96,800&family=Be+Vietnam+Pro:wght@500;700&family=Courier+Prime:wght@400;700&family=Press+Start+2P&display=swap" rel="stylesheet"/>
|
| 10 |
+
</head>
|
| 11 |
+
<body>
|
| 12 |
+
<div id="root"></div>
|
| 13 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 14 |
+
</body>
|
| 15 |
+
</html>
|
algospaced-ui/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
algospaced-ui/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "algospaced-ui",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "tsc -b && vite build",
|
| 9 |
+
"lint": "eslint .",
|
| 10 |
+
"preview": "vite preview"
|
| 11 |
+
},
|
| 12 |
+
"dependencies": {
|
| 13 |
+
"@codemirror/lang-python": "^6.2.1",
|
| 14 |
+
"@supabase/supabase-js": "^2.108.2",
|
| 15 |
+
"@uiw/react-codemirror": "^4.25.10",
|
| 16 |
+
"lucide-react": "^1.21.0",
|
| 17 |
+
"react": "^19.2.6",
|
| 18 |
+
"react-dom": "^19.2.6"
|
| 19 |
+
},
|
| 20 |
+
"devDependencies": {
|
| 21 |
+
"@eslint/js": "^10.0.1",
|
| 22 |
+
"@tailwindcss/vite": "^4.3.1",
|
| 23 |
+
"@types/node": "^24.12.3",
|
| 24 |
+
"@types/react": "^19.2.14",
|
| 25 |
+
"@types/react-dom": "^19.2.3",
|
| 26 |
+
"@vitejs/plugin-react": "^6.0.1",
|
| 27 |
+
"autoprefixer": "^10.5.0",
|
| 28 |
+
"eslint": "^10.3.0",
|
| 29 |
+
"eslint-plugin-react-hooks": "^7.1.1",
|
| 30 |
+
"eslint-plugin-react-refresh": "^0.5.2",
|
| 31 |
+
"globals": "^17.6.0",
|
| 32 |
+
"postcss": "^8.5.15",
|
| 33 |
+
"tailwindcss": "^4.3.1",
|
| 34 |
+
"typescript": "~6.0.2",
|
| 35 |
+
"typescript-eslint": "^8.59.2",
|
| 36 |
+
"vite": "^8.0.12"
|
| 37 |
+
}
|
| 38 |
+
}
|
algospaced-ui/public/favicon.svg
ADDED
|
|
algospaced-ui/public/icons.svg
ADDED
|
|
algospaced-ui/src/App.css
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.counter {
|
| 2 |
+
font-size: 16px;
|
| 3 |
+
padding: 5px 10px;
|
| 4 |
+
border-radius: 5px;
|
| 5 |
+
color: var(--accent);
|
| 6 |
+
background: var(--accent-bg);
|
| 7 |
+
border: 2px solid transparent;
|
| 8 |
+
transition: border-color 0.3s;
|
| 9 |
+
margin-bottom: 24px;
|
| 10 |
+
|
| 11 |
+
&:hover {
|
| 12 |
+
border-color: var(--accent-border);
|
| 13 |
+
}
|
| 14 |
+
&:focus-visible {
|
| 15 |
+
outline: 2px solid var(--accent);
|
| 16 |
+
outline-offset: 2px;
|
| 17 |
+
}
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
.hero {
|
| 21 |
+
position: relative;
|
| 22 |
+
|
| 23 |
+
.base,
|
| 24 |
+
.framework,
|
| 25 |
+
.vite {
|
| 26 |
+
inset-inline: 0;
|
| 27 |
+
margin: 0 auto;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
.base {
|
| 31 |
+
width: 170px;
|
| 32 |
+
position: relative;
|
| 33 |
+
z-index: 0;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
.framework,
|
| 37 |
+
.vite {
|
| 38 |
+
position: absolute;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
.framework {
|
| 42 |
+
z-index: 1;
|
| 43 |
+
top: 34px;
|
| 44 |
+
height: 28px;
|
| 45 |
+
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
| 46 |
+
scale(1.4);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
.vite {
|
| 50 |
+
z-index: 0;
|
| 51 |
+
top: 107px;
|
| 52 |
+
height: 26px;
|
| 53 |
+
width: auto;
|
| 54 |
+
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
| 55 |
+
scale(0.8);
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
#center {
|
| 60 |
+
display: flex;
|
| 61 |
+
flex-direction: column;
|
| 62 |
+
gap: 25px;
|
| 63 |
+
place-content: center;
|
| 64 |
+
place-items: center;
|
| 65 |
+
flex-grow: 1;
|
| 66 |
+
|
| 67 |
+
@media (max-width: 1024px) {
|
| 68 |
+
padding: 32px 20px 24px;
|
| 69 |
+
gap: 18px;
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
#next-steps {
|
| 74 |
+
display: flex;
|
| 75 |
+
border-top: 1px solid var(--border);
|
| 76 |
+
text-align: left;
|
| 77 |
+
|
| 78 |
+
& > div {
|
| 79 |
+
flex: 1 1 0;
|
| 80 |
+
padding: 32px;
|
| 81 |
+
@media (max-width: 1024px) {
|
| 82 |
+
padding: 24px 20px;
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
.icon {
|
| 87 |
+
margin-bottom: 16px;
|
| 88 |
+
width: 22px;
|
| 89 |
+
height: 22px;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
@media (max-width: 1024px) {
|
| 93 |
+
flex-direction: column;
|
| 94 |
+
text-align: center;
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
#docs {
|
| 99 |
+
border-right: 1px solid var(--border);
|
| 100 |
+
|
| 101 |
+
@media (max-width: 1024px) {
|
| 102 |
+
border-right: none;
|
| 103 |
+
border-bottom: 1px solid var(--border);
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
#next-steps ul {
|
| 108 |
+
list-style: none;
|
| 109 |
+
padding: 0;
|
| 110 |
+
display: flex;
|
| 111 |
+
gap: 8px;
|
| 112 |
+
margin: 32px 0 0;
|
| 113 |
+
|
| 114 |
+
.logo {
|
| 115 |
+
height: 18px;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
a {
|
| 119 |
+
color: var(--text-h);
|
| 120 |
+
font-size: 16px;
|
| 121 |
+
border-radius: 6px;
|
| 122 |
+
background: var(--social-bg);
|
| 123 |
+
display: flex;
|
| 124 |
+
padding: 6px 12px;
|
| 125 |
+
align-items: center;
|
| 126 |
+
gap: 8px;
|
| 127 |
+
text-decoration: none;
|
| 128 |
+
transition: box-shadow 0.3s;
|
| 129 |
+
|
| 130 |
+
&:hover {
|
| 131 |
+
box-shadow: var(--shadow);
|
| 132 |
+
}
|
| 133 |
+
.button-icon {
|
| 134 |
+
height: 18px;
|
| 135 |
+
width: 18px;
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
@media (max-width: 1024px) {
|
| 140 |
+
margin-top: 20px;
|
| 141 |
+
flex-wrap: wrap;
|
| 142 |
+
justify-content: center;
|
| 143 |
+
|
| 144 |
+
li {
|
| 145 |
+
flex: 1 1 calc(50% - 8px);
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
a {
|
| 149 |
+
width: 100%;
|
| 150 |
+
justify-content: center;
|
| 151 |
+
box-sizing: border-box;
|
| 152 |
+
}
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
#spacer {
|
| 157 |
+
height: 88px;
|
| 158 |
+
border-top: 1px solid var(--border);
|
| 159 |
+
@media (max-width: 1024px) {
|
| 160 |
+
height: 48px;
|
| 161 |
+
}
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
.ticks {
|
| 165 |
+
position: relative;
|
| 166 |
+
width: 100%;
|
| 167 |
+
|
| 168 |
+
&::before,
|
| 169 |
+
&::after {
|
| 170 |
+
content: '';
|
| 171 |
+
position: absolute;
|
| 172 |
+
top: -4.5px;
|
| 173 |
+
border: 5px solid transparent;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
&::before {
|
| 177 |
+
left: 0;
|
| 178 |
+
border-left-color: var(--border);
|
| 179 |
+
}
|
| 180 |
+
&::after {
|
| 181 |
+
right: 0;
|
| 182 |
+
border-right-color: var(--border);
|
| 183 |
+
}
|
| 184 |
+
}
|
algospaced-ui/src/App.tsx
ADDED
|
@@ -0,0 +1,895 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from 'react';
|
| 2 |
+
import { supabase } from './supabaseClient';
|
| 3 |
+
import Auth from './components/Auth';
|
| 4 |
+
import ReviewQueue from './components/ReviewQueue';
|
| 5 |
+
import JourneyPath from './components/JourneyPath';
|
| 6 |
+
import WindowShell from './components/WindowShell';
|
| 7 |
+
import TerminalEmulator from './components/TerminalEmulator';
|
| 8 |
+
import PythonChallenge from './components/PythonChallenge';
|
| 9 |
+
import MySQLPlayground from './components/MySQLPlayground';
|
| 10 |
+
import FileExplorer from './components/FileExplorer';
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
interface WindowState {
|
| 15 |
+
isOpen: boolean;
|
| 16 |
+
isMinimized: boolean;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export default function App() {
|
| 20 |
+
const [user, setUser] = useState<any>(null);
|
| 21 |
+
const [overrideProblemId, setOverrideProblemId] = useState<string | null>(null);
|
| 22 |
+
|
| 23 |
+
// Window states
|
| 24 |
+
const [windows, setWindows] = useState<Record<string, WindowState>>({
|
| 25 |
+
queue: { isOpen: true, isMinimized: false },
|
| 26 |
+
journey: { isOpen: false, isMinimized: false },
|
| 27 |
+
terminal: { isOpen: false, isMinimized: false },
|
| 28 |
+
computer: { isOpen: false, isMinimized: false },
|
| 29 |
+
sync: { isOpen: false, isMinimized: false },
|
| 30 |
+
python: { isOpen: false, isMinimized: false },
|
| 31 |
+
mysql: { isOpen: false, isMinimized: false },
|
| 32 |
+
});
|
| 33 |
+
const [activeWindow, setActiveWindow] = useState<string>('queue');
|
| 34 |
+
|
| 35 |
+
// Start Menu and System info
|
| 36 |
+
const [isStartOpen, setIsStartOpen] = useState(false);
|
| 37 |
+
const [streak, setStreak] = useState<any>(null);
|
| 38 |
+
const [time, setTime] = useState('');
|
| 39 |
+
|
| 40 |
+
// Sync state
|
| 41 |
+
const [syncing, setSyncing] = useState(false);
|
| 42 |
+
const [syncResult, setSyncResult] = useState<string | null>(null);
|
| 43 |
+
|
| 44 |
+
// Wallpaper state
|
| 45 |
+
const [wallpaperUrl, setWallpaperUrl] = useState<string>(() => {
|
| 46 |
+
return localStorage.getItem('desktopWallpaperUrl') || 'https://lh3.googleusercontent.com/aida-public/AB6AXuAMJHVhhK8_G7F8sbhn8F7w4AZWXb1O_-HLKvrZ_fJlbUVMcVuxxEOO_LlOh8qIwtAn2KvzvCwzmtNLLEZ5uYkCEsx8ZWXJR609qgSMRSX8LBBeskk4VzVXnyzsgIKCedeV2PvGxJlUNnledcWKCXqG9egQi8dgTcA7C2z82QyM73KZ4s7ZRzZHNupuQt1ocfcMl9E_x1PWFRridl751LIpyGZgednS5CmVw2rZvFc_tbp2QTxgVHJ_59myEBmy6aajbO06AhkKmCvI';
|
| 47 |
+
});
|
| 48 |
+
|
| 49 |
+
// Context menu state
|
| 50 |
+
const [contextMenu, setContextMenu] = useState<{ visible: boolean; x: number; y: number }>({
|
| 51 |
+
visible: false,
|
| 52 |
+
x: 0,
|
| 53 |
+
y: 0
|
| 54 |
+
});
|
| 55 |
+
|
| 56 |
+
// Fullscreen state
|
| 57 |
+
const [isFullscreen, setIsFullscreen] = useState(false);
|
| 58 |
+
|
| 59 |
+
// Fetch streak info
|
| 60 |
+
const fetchStreak = async () => {
|
| 61 |
+
try {
|
| 62 |
+
const { data: { session } } = await supabase.auth.getSession();
|
| 63 |
+
if (!session) return;
|
| 64 |
+
|
| 65 |
+
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/streak`, {
|
| 66 |
+
headers: { 'Authorization': `Bearer ${session.access_token}` }
|
| 67 |
+
});
|
| 68 |
+
if (res.ok) {
|
| 69 |
+
const data = await res.json();
|
| 70 |
+
setStreak(data);
|
| 71 |
+
}
|
| 72 |
+
} catch (err) {
|
| 73 |
+
console.error('Error fetching streak:', err);
|
| 74 |
+
}
|
| 75 |
+
};
|
| 76 |
+
|
| 77 |
+
useEffect(() => {
|
| 78 |
+
// Check initial session
|
| 79 |
+
supabase.auth.getSession().then(({ data: { session } }) => {
|
| 80 |
+
setUser(session?.user ?? null);
|
| 81 |
+
});
|
| 82 |
+
|
| 83 |
+
// Listen for auth state changes
|
| 84 |
+
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
|
| 85 |
+
setUser(session?.user ?? null);
|
| 86 |
+
});
|
| 87 |
+
|
| 88 |
+
// Clock Updater
|
| 89 |
+
const updateTime = () => {
|
| 90 |
+
const date = new Date();
|
| 91 |
+
let hours = date.getHours();
|
| 92 |
+
const minutes = date.getMinutes();
|
| 93 |
+
const ampm = hours >= 12 ? 'PM' : 'AM';
|
| 94 |
+
hours = hours % 12;
|
| 95 |
+
hours = hours ? hours : 12; // 0 is 12
|
| 96 |
+
const minStr = minutes < 10 ? '0' + minutes : minutes;
|
| 97 |
+
setTime(`${hours}:${minStr} ${ampm}`);
|
| 98 |
+
};
|
| 99 |
+
updateTime();
|
| 100 |
+
const interval = setInterval(updateTime, 30000);
|
| 101 |
+
|
| 102 |
+
return () => {
|
| 103 |
+
subscription.unsubscribe();
|
| 104 |
+
clearInterval(interval);
|
| 105 |
+
};
|
| 106 |
+
}, []);
|
| 107 |
+
|
| 108 |
+
useEffect(() => {
|
| 109 |
+
if (user) {
|
| 110 |
+
fetchStreak();
|
| 111 |
+
const savedWallpaper = localStorage.getItem('desktopWallpaperUrl');
|
| 112 |
+
if (savedWallpaper) {
|
| 113 |
+
setWallpaperUrl(savedWallpaper);
|
| 114 |
+
}
|
| 115 |
+
}
|
| 116 |
+
}, [user]);
|
| 117 |
+
|
| 118 |
+
// Fullscreen change listener
|
| 119 |
+
useEffect(() => {
|
| 120 |
+
const handleFullscreenChange = () => {
|
| 121 |
+
setIsFullscreen(!!document.fullscreenElement);
|
| 122 |
+
};
|
| 123 |
+
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
| 124 |
+
return () => {
|
| 125 |
+
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
| 126 |
+
};
|
| 127 |
+
}, []);
|
| 128 |
+
|
| 129 |
+
// Zoom prevention listener
|
| 130 |
+
useEffect(() => {
|
| 131 |
+
const handleWheel = (e: WheelEvent) => {
|
| 132 |
+
if (e.ctrlKey) {
|
| 133 |
+
e.preventDefault();
|
| 134 |
+
}
|
| 135 |
+
};
|
| 136 |
+
|
| 137 |
+
const handleKeydown = (e: KeyboardEvent) => {
|
| 138 |
+
if (e.ctrlKey && (
|
| 139 |
+
e.key === '=' ||
|
| 140 |
+
e.key === '-' ||
|
| 141 |
+
e.key === '0' ||
|
| 142 |
+
e.key === '+' ||
|
| 143 |
+
e.code === 'NumpadAdd' ||
|
| 144 |
+
e.code === 'NumpadSubtract'
|
| 145 |
+
)) {
|
| 146 |
+
e.preventDefault();
|
| 147 |
+
}
|
| 148 |
+
};
|
| 149 |
+
|
| 150 |
+
const handleTouchmove = (e: TouchEvent) => {
|
| 151 |
+
if (e.touches.length > 1) {
|
| 152 |
+
e.preventDefault();
|
| 153 |
+
}
|
| 154 |
+
};
|
| 155 |
+
|
| 156 |
+
document.addEventListener('wheel', handleWheel, { passive: false });
|
| 157 |
+
document.addEventListener('keydown', handleKeydown);
|
| 158 |
+
document.addEventListener('touchmove', handleTouchmove, { passive: false });
|
| 159 |
+
|
| 160 |
+
return () => {
|
| 161 |
+
document.removeEventListener('wheel', handleWheel);
|
| 162 |
+
document.removeEventListener('keydown', handleKeydown);
|
| 163 |
+
document.removeEventListener('touchmove', handleTouchmove);
|
| 164 |
+
};
|
| 165 |
+
}, []);
|
| 166 |
+
|
| 167 |
+
const handleContextMenu = (e: React.MouseEvent) => {
|
| 168 |
+
const target = e.target as HTMLElement;
|
| 169 |
+
// Prevent menu if clicking interactive windows/buttons/inputs
|
| 170 |
+
if (
|
| 171 |
+
target.closest('.window-shadow') ||
|
| 172 |
+
target.closest('button') ||
|
| 173 |
+
target.closest('input') ||
|
| 174 |
+
target.closest('textarea') ||
|
| 175 |
+
target.closest('details') ||
|
| 176 |
+
target.closest('#start-menu')
|
| 177 |
+
) {
|
| 178 |
+
return;
|
| 179 |
+
}
|
| 180 |
+
e.preventDefault();
|
| 181 |
+
setContextMenu({
|
| 182 |
+
visible: true,
|
| 183 |
+
x: e.clientX,
|
| 184 |
+
y: e.clientY
|
| 185 |
+
});
|
| 186 |
+
};
|
| 187 |
+
|
| 188 |
+
const closeContextMenu = () => {
|
| 189 |
+
if (contextMenu.visible) {
|
| 190 |
+
setContextMenu(prev => ({ ...prev, visible: false }));
|
| 191 |
+
}
|
| 192 |
+
};
|
| 193 |
+
|
| 194 |
+
const toggleFullscreen = () => {
|
| 195 |
+
closeContextMenu();
|
| 196 |
+
if (!document.fullscreenElement) {
|
| 197 |
+
document.documentElement.requestFullscreen().catch((err) => {
|
| 198 |
+
console.error(`Error enabling fullscreen: ${err.message}`);
|
| 199 |
+
});
|
| 200 |
+
} else {
|
| 201 |
+
document.exitFullscreen();
|
| 202 |
+
}
|
| 203 |
+
};
|
| 204 |
+
|
| 205 |
+
const compressAndSaveWallpaper = (file: File) => {
|
| 206 |
+
const reader = new FileReader();
|
| 207 |
+
reader.onload = (event) => {
|
| 208 |
+
const img = new Image();
|
| 209 |
+
img.onload = () => {
|
| 210 |
+
const canvas = document.createElement('canvas');
|
| 211 |
+
let width = img.width;
|
| 212 |
+
let height = img.height;
|
| 213 |
+
|
| 214 |
+
const MAX_WIDTH = 1920;
|
| 215 |
+
const MAX_HEIGHT = 1080;
|
| 216 |
+
|
| 217 |
+
if (width > MAX_WIDTH || height > MAX_HEIGHT) {
|
| 218 |
+
if (width / height > MAX_WIDTH / MAX_HEIGHT) {
|
| 219 |
+
height = Math.round((height * MAX_WIDTH) / width);
|
| 220 |
+
width = MAX_WIDTH;
|
| 221 |
+
} else {
|
| 222 |
+
width = Math.round((width * MAX_HEIGHT) / height);
|
| 223 |
+
height = MAX_HEIGHT;
|
| 224 |
+
}
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
canvas.width = width;
|
| 228 |
+
canvas.height = height;
|
| 229 |
+
const ctx = canvas.getContext('2d');
|
| 230 |
+
if (ctx) {
|
| 231 |
+
ctx.drawImage(img, 0, 0, width, height);
|
| 232 |
+
try {
|
| 233 |
+
const dataUrl = canvas.toDataURL('image/jpeg', 0.7);
|
| 234 |
+
setWallpaperUrl(dataUrl);
|
| 235 |
+
localStorage.setItem('desktopWallpaperUrl', dataUrl);
|
| 236 |
+
localStorage.setItem('customWallpaperUrl', dataUrl);
|
| 237 |
+
localStorage.setItem('customWallpaperName', file.name);
|
| 238 |
+
} catch (err) {
|
| 239 |
+
console.error('Failed to save compressed image to localStorage:', err);
|
| 240 |
+
// Fallback: set it as state but warn
|
| 241 |
+
const rawDataUrl = event.target?.result as string;
|
| 242 |
+
setWallpaperUrl(rawDataUrl);
|
| 243 |
+
}
|
| 244 |
+
} else {
|
| 245 |
+
const rawDataUrl = event.target?.result as string;
|
| 246 |
+
setWallpaperUrl(rawDataUrl);
|
| 247 |
+
try {
|
| 248 |
+
localStorage.setItem('desktopWallpaperUrl', rawDataUrl);
|
| 249 |
+
localStorage.setItem('customWallpaperUrl', rawDataUrl);
|
| 250 |
+
localStorage.setItem('customWallpaperName', file.name);
|
| 251 |
+
} catch (err) {
|
| 252 |
+
console.error('Failed to save raw image to localStorage:', err);
|
| 253 |
+
}
|
| 254 |
+
}
|
| 255 |
+
};
|
| 256 |
+
img.src = event.target?.result as string;
|
| 257 |
+
};
|
| 258 |
+
reader.readAsDataURL(file);
|
| 259 |
+
};
|
| 260 |
+
|
| 261 |
+
const handleWallpaperChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 262 |
+
closeContextMenu();
|
| 263 |
+
const file = e.target.files?.[0];
|
| 264 |
+
if (file) {
|
| 265 |
+
compressAndSaveWallpaper(file);
|
| 266 |
+
}
|
| 267 |
+
};
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
const openWindow = (id: string) => {
|
| 271 |
+
setWindows(prev => ({
|
| 272 |
+
...prev,
|
| 273 |
+
[id]: { isOpen: true, isMinimized: false }
|
| 274 |
+
}));
|
| 275 |
+
setActiveWindow(id);
|
| 276 |
+
setIsStartOpen(false);
|
| 277 |
+
};
|
| 278 |
+
|
| 279 |
+
const closeWindow = (id: string) => {
|
| 280 |
+
setWindows(prev => ({
|
| 281 |
+
...prev,
|
| 282 |
+
[id]: { ...prev[id], isOpen: false }
|
| 283 |
+
}));
|
| 284 |
+
};
|
| 285 |
+
|
| 286 |
+
const minimizeWindow = (id: string) => {
|
| 287 |
+
setWindows(prev => ({
|
| 288 |
+
...prev,
|
| 289 |
+
[id]: { ...prev[id], isMinimized: true }
|
| 290 |
+
}));
|
| 291 |
+
};
|
| 292 |
+
|
| 293 |
+
const toggleWindowMinimize = (id: string) => {
|
| 294 |
+
setWindows(prev => {
|
| 295 |
+
const win = prev[id];
|
| 296 |
+
if (win.isMinimized || !win.isOpen) {
|
| 297 |
+
setActiveWindow(id);
|
| 298 |
+
return {
|
| 299 |
+
...prev,
|
| 300 |
+
[id]: { isOpen: true, isMinimized: false }
|
| 301 |
+
};
|
| 302 |
+
} else if (activeWindow === id) {
|
| 303 |
+
return {
|
| 304 |
+
...prev,
|
| 305 |
+
[id]: { ...win, isMinimized: true }
|
| 306 |
+
};
|
| 307 |
+
} else {
|
| 308 |
+
setActiveWindow(id);
|
| 309 |
+
return prev;
|
| 310 |
+
}
|
| 311 |
+
});
|
| 312 |
+
};
|
| 313 |
+
|
| 314 |
+
const handleSelectProblemForReview = (problemId: string) => {
|
| 315 |
+
setOverrideProblemId(problemId);
|
| 316 |
+
openWindow('queue');
|
| 317 |
+
};
|
| 318 |
+
|
| 319 |
+
const triggerSync = async () => {
|
| 320 |
+
openWindow('sync');
|
| 321 |
+
setSyncing(true);
|
| 322 |
+
setSyncResult(null);
|
| 323 |
+
try {
|
| 324 |
+
const { data: { session } } = await supabase.auth.getSession();
|
| 325 |
+
if (!session) return;
|
| 326 |
+
|
| 327 |
+
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/sync`, {
|
| 328 |
+
method: 'POST',
|
| 329 |
+
headers: { 'Authorization': `Bearer ${session.access_token}` }
|
| 330 |
+
});
|
| 331 |
+
if (res.ok) {
|
| 332 |
+
const data = await res.json();
|
| 333 |
+
setSyncResult(data.message || 'Synchronization successfully completed!');
|
| 334 |
+
fetchStreak();
|
| 335 |
+
} else {
|
| 336 |
+
setSyncResult('Sync operation failed at server host.');
|
| 337 |
+
}
|
| 338 |
+
} catch {
|
| 339 |
+
setSyncResult('Cloud synchronization server unreachable.');
|
| 340 |
+
} finally {
|
| 341 |
+
setSyncing(false);
|
| 342 |
+
}
|
| 343 |
+
};
|
| 344 |
+
|
| 345 |
+
const handleSignOut = async () => {
|
| 346 |
+
setIsStartOpen(false);
|
| 347 |
+
await supabase.auth.signOut();
|
| 348 |
+
};
|
| 349 |
+
|
| 350 |
+
if (!user) {
|
| 351 |
+
return <Auth />;
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
const username = user.email ? user.email.split('@')[0] : 'Player_One';
|
| 355 |
+
|
| 356 |
+
return (
|
| 357 |
+
<div
|
| 358 |
+
onContextMenu={handleContextMenu}
|
| 359 |
+
onClick={closeContextMenu}
|
| 360 |
+
className="w-screen h-screen m-0 p-0 relative font-sans text-ink-black overflow-hidden select-none"
|
| 361 |
+
>
|
| 362 |
+
|
| 363 |
+
{/* Background Wallpaper image */}
|
| 364 |
+
<div
|
| 365 |
+
className="absolute inset-0 w-full h-full bg-cover bg-center z-[-1] saturate-150 contrast-125 transition-all duration-300"
|
| 366 |
+
style={{ backgroundImage: `url('${wallpaperUrl}')` }}
|
| 367 |
+
/>
|
| 368 |
+
|
| 369 |
+
{/* Desktop Icons column */}
|
| 370 |
+
<nav className="fixed left-0 top-12 h-[calc(100vh-80px)] w-[140px] flex flex-col items-center py-gutter-md space-y-6 z-10 overflow-y-auto custom-scrollbar select-none">
|
| 371 |
+
|
| 372 |
+
{/* Daily Queue Shortcut */}
|
| 373 |
+
<button
|
| 374 |
+
onClick={() => toggleWindowMinimize('queue')}
|
| 375 |
+
className="flex flex-col items-center justify-center text-ink-black gap-2.5 p-2 w-full group icon-hover-shift cursor-pointer outline-none"
|
| 376 |
+
>
|
| 377 |
+
<div className="w-16 h-16 bg-paper-white border-[3px] border-ink-black flex items-center justify-center gadget-shadow group-hover:bg-secondary-container transition-colors shadow-[inset_2px_2px_0px_rgba(255,255,255,0.8)]">
|
| 378 |
+
<span className="material-symbols-outlined text-4xl" style={{ fontVariationSettings: "'FILL' 1" }}>
|
| 379 |
+
view_list
|
| 380 |
+
</span>
|
| 381 |
+
</div>
|
| 382 |
+
<span className="font-icon-label text-[9px] bg-ink-black text-white px-2 py-1 border-[3px] border-ink-black text-center leading-tight shadow-[2px_2px_0px_0px_#fde047] uppercase">
|
| 383 |
+
DAILY<br/>QUEUE
|
| 384 |
+
</span>
|
| 385 |
+
</button>
|
| 386 |
+
|
| 387 |
+
{/* Journey Path Shortcut */}
|
| 388 |
+
<button
|
| 389 |
+
onClick={() => toggleWindowMinimize('journey')}
|
| 390 |
+
className="flex flex-col items-center justify-center text-ink-black gap-2.5 p-2 w-full group icon-hover-shift cursor-pointer outline-none"
|
| 391 |
+
>
|
| 392 |
+
<div className="w-16 h-16 bg-paper-white border-[3px] border-ink-black flex items-center justify-center gadget-shadow group-hover:bg-secondary-container transition-colors shadow-[inset_2px_2px_0px_rgba(255,255,255,0.8)]">
|
| 393 |
+
<span className="material-symbols-outlined text-4xl" style={{ fontVariationSettings: "'FILL' 1" }}>
|
| 394 |
+
route
|
| 395 |
+
</span>
|
| 396 |
+
</div>
|
| 397 |
+
<span className="font-icon-label text-[9px] bg-ink-black text-white px-2 py-1 border-[3px] border-ink-black text-center leading-tight shadow-[2px_2px_0px_0px_#fde047] uppercase">
|
| 398 |
+
JOURNEY<br/>PATH
|
| 399 |
+
</span>
|
| 400 |
+
</button>
|
| 401 |
+
|
| 402 |
+
{/* Drive Sync Shortcut */}
|
| 403 |
+
<button
|
| 404 |
+
onClick={triggerSync}
|
| 405 |
+
className="flex flex-col items-center justify-center text-ink-black gap-2.5 p-2 w-full group icon-hover-shift cursor-pointer outline-none"
|
| 406 |
+
>
|
| 407 |
+
<div className="w-16 h-16 bg-paper-white border-[3px] border-ink-black flex items-center justify-center gadget-shadow group-hover:bg-secondary-container transition-colors shadow-[inset_2px_2px_0px_rgba(255,255,255,0.8)]">
|
| 408 |
+
<span className="material-symbols-outlined text-4xl" style={{ fontVariationSettings: "'FILL' 1" }}>
|
| 409 |
+
cloud_sync
|
| 410 |
+
</span>
|
| 411 |
+
</div>
|
| 412 |
+
<span className="font-icon-label text-[9px] bg-ink-black text-white px-2 py-1 border-[3px] border-ink-black text-center leading-tight shadow-[2px_2px_0px_0px_#fde047] uppercase">
|
| 413 |
+
DRIVE<br/>SYNC
|
| 414 |
+
</span>
|
| 415 |
+
</button>
|
| 416 |
+
|
| 417 |
+
{/* My Computer Shortcut */}
|
| 418 |
+
<button
|
| 419 |
+
onClick={() => toggleWindowMinimize('computer')}
|
| 420 |
+
className="flex flex-col items-center justify-center text-ink-black gap-2.5 p-2 w-full group icon-hover-shift cursor-pointer outline-none"
|
| 421 |
+
>
|
| 422 |
+
<div className="w-16 h-16 bg-paper-white border-[3px] border-ink-black flex items-center justify-center gadget-shadow group-hover:bg-secondary-container transition-colors shadow-[inset_2px_2px_0px_rgba(255,255,255,0.8)] p-1 overflow-hidden">
|
| 423 |
+
<img
|
| 424 |
+
alt="My Computer"
|
| 425 |
+
className="w-full h-full object-cover"
|
| 426 |
+
src="https://lh3.googleusercontent.com/aida-public/AB6AXuD2tUGA2d7QiNyajDAWYK183zhNTgtAZpOXK3E9wRvHGs-t6ykWt9uPScYe_fziWzQI0pREcY_ThI341WvGMusyYmnAkagfuwx6wubIs1ES68DO8CCNAlcHcb2zOUU4MJeYuhWDy1uYRqyGYjIaDUqfgNWk2vm4WzwRqoorn2dxtZ6QdJhEKbXjG8fG9chkkvr68qJf0fLUL6bIqBXZg2FJ30S7zS3oZ4IZsug-wLyRYuJ4Gu_86snNUo1whNrBdZm5OMREfc9sLbwJ"
|
| 427 |
+
/>
|
| 428 |
+
</div>
|
| 429 |
+
<span className="font-icon-label text-[9px] bg-ink-black text-white px-2 py-1 border-[3px] border-ink-black text-center leading-tight shadow-[2px_2px_0px_0px_#fde047] uppercase">
|
| 430 |
+
MY<br/>COMP
|
| 431 |
+
</span>
|
| 432 |
+
</button>
|
| 433 |
+
|
| 434 |
+
{/* Terminal Shortcut */}
|
| 435 |
+
<button
|
| 436 |
+
onClick={() => toggleWindowMinimize('terminal')}
|
| 437 |
+
className="flex flex-col items-center justify-center text-ink-black gap-2.5 p-2 w-full group icon-hover-shift cursor-pointer outline-none"
|
| 438 |
+
>
|
| 439 |
+
<div className="w-16 h-16 bg-paper-white border-[3px] border-ink-black flex items-center justify-center gadget-shadow group-hover:bg-secondary-container transition-colors shadow-[inset_2px_2px_0px_rgba(255,255,255,0.8)] p-1 overflow-hidden">
|
| 440 |
+
<img
|
| 441 |
+
alt="Terminal"
|
| 442 |
+
className="w-full h-full object-cover"
|
| 443 |
+
src="https://lh3.googleusercontent.com/aida-public/AB6AXuA2AvcQ2oKWP8AJ8yR5nF82LLDEH1FKySUK-npnqEjvJ6Cs9xp8joCwH7x43oTCAC5xhO2rQdYzka3B6u72FNbdxMnsv3WJLye3bKochu8AnnRlS3szfYSaj724sYsiZo4n8G-oWqvl7C0rOGaDaU2nRH1xXmW2PBi6L2NryTsUaTCVZQKTKBBNNEOig5kh7B52e-iIuj2PTzaSjF9ebp6gMuCnBtJT8Sxo5Qsmvfm-t2U5r_qQpRiYj--Ut41T7NTh-tHgs4S4kBvz"
|
| 444 |
+
/>
|
| 445 |
+
</div>
|
| 446 |
+
<span className="font-icon-label text-[9px] bg-ink-black text-white px-2 py-1 border-[3px] border-ink-black text-center leading-tight shadow-[2px_2px_0px_0px_#fde047] uppercase">
|
| 447 |
+
TERMINAL
|
| 448 |
+
</span>
|
| 449 |
+
</button>
|
| 450 |
+
</nav>
|
| 451 |
+
|
| 452 |
+
{/* System Gadget Widget (Top Right) */}
|
| 453 |
+
<div className="absolute top-desktop-margin right-desktop-margin w-64 bg-paper-white border-[3px] border-ink-black gadget-shadow z-20 overflow-hidden flex flex-col shadow-[inset_2px_2px_0px_rgba(255,255,255,0.8)] select-none">
|
| 454 |
+
<div className="bg-[#0ea5e9] border-b-[3px] border-ink-black px-3 py-1 flex justify-between items-center text-white">
|
| 455 |
+
<span className="font-window-title text-xs font-bold tracking-wide">Profile.sys</span>
|
| 456 |
+
</div>
|
| 457 |
+
<div className="p-3 flex items-center gap-3 bg-[#fdf8e1]">
|
| 458 |
+
<div className="w-12 h-12 border-[3px] border-ink-black overflow-hidden bg-primary-fixed-dim shadow-[2px_2px_0_0_#1E293B] shrink-0">
|
| 459 |
+
<img
|
| 460 |
+
className="w-full h-full object-cover"
|
| 461 |
+
src="https://lh3.googleusercontent.com/aida-public/AB6AXuBl1MnAmh_W_EWibibrGw1F1YSenuKlaIn3JymUnBKn1nPLjAaOBjoLzdT5krzVDmihFhXOk-DEtNTdM2Js1_Ttr--5WZohhyqD1QfpS6j_yU1_-4wweOmgWq2HLUYNMf0mzQDNLNu5KbACEJKL5sm1YaTLPePlO687X3VHYsUhWTpUQeBwFjushaBpyW8tEa2dJj5ZSRWq0qa5GhtE0zAAW741e2SIRtFOp7CvBYEEetymSiucK513lli1TUwgWIgXvlfxZoGbH00q"
|
| 462 |
+
/>
|
| 463 |
+
</div>
|
| 464 |
+
<div className="overflow-hidden">
|
| 465 |
+
<div className="font-headline-md text-sm font-bold text-ink-black truncate">{username}</div>
|
| 466 |
+
<div className="flex items-center gap-1 text-red-500 mt-1 select-none font-bold">
|
| 467 |
+
<span className="material-symbols-outlined text-sm" style={{ fontVariationSettings: "'FILL' 1" }}>
|
| 468 |
+
local_fire_department
|
| 469 |
+
</span>
|
| 470 |
+
<span className="font-arcade text-[7px]">{streak?.current_streak ?? 0} DAY STREAK</span>
|
| 471 |
+
</div>
|
| 472 |
+
</div>
|
| 473 |
+
</div>
|
| 474 |
+
</div>
|
| 475 |
+
|
| 476 |
+
{/* Floating Windows Shell Containers */}
|
| 477 |
+
|
| 478 |
+
{/* 1. Daily Review Queue Window */}
|
| 479 |
+
<WindowShell
|
| 480 |
+
id="queue"
|
| 481 |
+
title="Daily Queue.exe"
|
| 482 |
+
icon="terminal"
|
| 483 |
+
isOpen={windows.queue.isOpen}
|
| 484 |
+
isMinimized={windows.queue.isMinimized}
|
| 485 |
+
onClose={() => closeWindow('queue')}
|
| 486 |
+
onMinimize={() => minimizeWindow('queue')}
|
| 487 |
+
activeWindow={activeWindow}
|
| 488 |
+
setActiveWindow={setActiveWindow}
|
| 489 |
+
defaultWidth="750px"
|
| 490 |
+
defaultHeight="600px"
|
| 491 |
+
>
|
| 492 |
+
<ReviewQueue
|
| 493 |
+
overrideProblemId={overrideProblemId}
|
| 494 |
+
onClearOverride={() => setOverrideProblemId(null)}
|
| 495 |
+
/>
|
| 496 |
+
</WindowShell>
|
| 497 |
+
|
| 498 |
+
{/* 2. Journey Path Map Window */}
|
| 499 |
+
<WindowShell
|
| 500 |
+
id="journey"
|
| 501 |
+
title="Journey_Path.exe"
|
| 502 |
+
icon="route"
|
| 503 |
+
isOpen={windows.journey.isOpen}
|
| 504 |
+
isMinimized={windows.journey.isMinimized}
|
| 505 |
+
onClose={() => closeWindow('journey')}
|
| 506 |
+
onMinimize={() => minimizeWindow('journey')}
|
| 507 |
+
activeWindow={activeWindow}
|
| 508 |
+
setActiveWindow={setActiveWindow}
|
| 509 |
+
defaultWidth="700px"
|
| 510 |
+
defaultHeight="560px"
|
| 511 |
+
>
|
| 512 |
+
<JourneyPath
|
| 513 |
+
onSelectProblemForReview={handleSelectProblemForReview}
|
| 514 |
+
/>
|
| 515 |
+
</WindowShell>
|
| 516 |
+
|
| 517 |
+
{/* 3. Command Line Terminal Window */}
|
| 518 |
+
<WindowShell
|
| 519 |
+
id="terminal"
|
| 520 |
+
title="Arcade Terminal.com"
|
| 521 |
+
icon="terminal"
|
| 522 |
+
isOpen={windows.terminal.isOpen}
|
| 523 |
+
isMinimized={windows.terminal.isMinimized}
|
| 524 |
+
onClose={() => closeWindow('terminal')}
|
| 525 |
+
onMinimize={() => minimizeWindow('terminal')}
|
| 526 |
+
activeWindow={activeWindow}
|
| 527 |
+
setActiveWindow={setActiveWindow}
|
| 528 |
+
defaultWidth="640px"
|
| 529 |
+
defaultHeight="450px"
|
| 530 |
+
titleBarColor="#1e293b"
|
| 531 |
+
>
|
| 532 |
+
<TerminalEmulator
|
| 533 |
+
onOpenWindow={openWindow}
|
| 534 |
+
onClose={() => closeWindow('terminal')}
|
| 535 |
+
/>
|
| 536 |
+
</WindowShell>
|
| 537 |
+
|
| 538 |
+
{/* 4. Google Drive Sync Dialog Window */}
|
| 539 |
+
<WindowShell
|
| 540 |
+
id="sync"
|
| 541 |
+
title="Drive_Sync.sys"
|
| 542 |
+
icon="cloud_sync"
|
| 543 |
+
isOpen={windows.sync.isOpen}
|
| 544 |
+
isMinimized={windows.sync.isMinimized}
|
| 545 |
+
onClose={() => closeWindow('sync')}
|
| 546 |
+
onMinimize={() => minimizeWindow('sync')}
|
| 547 |
+
activeWindow={activeWindow}
|
| 548 |
+
setActiveWindow={setActiveWindow}
|
| 549 |
+
defaultWidth="480px"
|
| 550 |
+
defaultHeight="320px"
|
| 551 |
+
titleBarColor="#855400"
|
| 552 |
+
>
|
| 553 |
+
<div className="w-full h-full bg-[#fdf8e1] p-6 flex flex-col justify-between">
|
| 554 |
+
<div className="space-y-4">
|
| 555 |
+
<div className="flex items-center gap-3 border-b-2 border-dashed border-ink-black pb-3">
|
| 556 |
+
<span className="material-symbols-outlined text-4xl text-[#855400] animate-bounce">cloud_sync</span>
|
| 557 |
+
<div>
|
| 558 |
+
<h4 className="font-arcade text-[10px] font-bold text-ink-black">DRIVE NOTES SYNCHRONIZATION</h4>
|
| 559 |
+
<span className="text-[8px] font-arcade text-trash-gray font-bold uppercase mt-1 block">Importing LeetCode sheets</span>
|
| 560 |
+
</div>
|
| 561 |
+
</div>
|
| 562 |
+
|
| 563 |
+
{syncing ? (
|
| 564 |
+
<div className="space-y-3 py-2">
|
| 565 |
+
<span className="text-[9px] font-arcade text-ink-black font-bold block animate-pulse">SYNC PROCESS RUNNING...</span>
|
| 566 |
+
<div className="w-full h-6 bg-white border-[3px] border-ink-black relative overflow-hidden select-none">
|
| 567 |
+
<div className="absolute inset-y-0 left-0 bg-[#ffe24c] border-r-2 border-ink-black w-[75%] animate-pulse" />
|
| 568 |
+
</div>
|
| 569 |
+
<p className="text-[9px] font-sans text-trash-gray leading-normal font-semibold">
|
| 570 |
+
Connecting to Supabase partition, requesting Google Drive manifest, indexing note items...
|
| 571 |
+
</p>
|
| 572 |
+
</div>
|
| 573 |
+
) : (
|
| 574 |
+
<div className="p-3 bg-white border-[3px] border-ink-black text-xs font-semibold text-ink-black leading-relaxed shadow-[2px_2px_0px_0px_#1E293B]">
|
| 575 |
+
{syncResult || 'Idle. Ready to synchronize Leitner box data logs from Sheets drive storage.'}
|
| 576 |
+
</div>
|
| 577 |
+
)}
|
| 578 |
+
</div>
|
| 579 |
+
|
| 580 |
+
<div className="flex gap-3 justify-end pt-4 border-t border-slate-200">
|
| 581 |
+
<button
|
| 582 |
+
onClick={() => closeWindow('sync')}
|
| 583 |
+
className="px-4 py-2 bg-white hover:bg-slate-100 text-ink-black border-[3px] border-ink-black font-bold text-xs shadow-[2px_2px_0px_0px_#1E293B] active:translate-y-0.5 active:shadow-none cursor-pointer"
|
| 584 |
+
>
|
| 585 |
+
CLOSE
|
| 586 |
+
</button>
|
| 587 |
+
<button
|
| 588 |
+
onClick={triggerSync}
|
| 589 |
+
disabled={syncing}
|
| 590 |
+
className="px-4 py-2 bg-[#ffcc00] hover:bg-yellow-300 text-ink-black border-[3px] border-ink-black font-bold text-xs shadow-[2px_2px_0px_0px_#1E293B] active:translate-y-0.5 active:shadow-none disabled:opacity-50 cursor-pointer"
|
| 591 |
+
>
|
| 592 |
+
{syncing ? 'SYNCING...' : 'SYNC NOW'}
|
| 593 |
+
</button>
|
| 594 |
+
</div>
|
| 595 |
+
</div>
|
| 596 |
+
</WindowShell>
|
| 597 |
+
|
| 598 |
+
{/* 5. My Computer Window (Interactive File Explorer) */}
|
| 599 |
+
<WindowShell
|
| 600 |
+
id="computer"
|
| 601 |
+
title="File Explorer"
|
| 602 |
+
icon="desktop_windows"
|
| 603 |
+
isOpen={windows.computer.isOpen}
|
| 604 |
+
isMinimized={windows.computer.isMinimized}
|
| 605 |
+
onClose={() => closeWindow('computer')}
|
| 606 |
+
onMinimize={() => minimizeWindow('computer')}
|
| 607 |
+
activeWindow={activeWindow}
|
| 608 |
+
setActiveWindow={setActiveWindow}
|
| 609 |
+
defaultWidth="640px"
|
| 610 |
+
defaultHeight="480px"
|
| 611 |
+
titleBarColor="#006686"
|
| 612 |
+
>
|
| 613 |
+
<FileExplorer onSetWallpaper={setWallpaperUrl} activeWallpaperUrl={wallpaperUrl} />
|
| 614 |
+
</WindowShell>
|
| 615 |
+
|
| 616 |
+
{/* 6. Daily Python Challenge Window */}
|
| 617 |
+
<WindowShell
|
| 618 |
+
id="python"
|
| 619 |
+
title="Python Challenge"
|
| 620 |
+
icon="sports_esports"
|
| 621 |
+
isOpen={windows.python.isOpen}
|
| 622 |
+
isMinimized={windows.python.isMinimized}
|
| 623 |
+
onClose={() => closeWindow('python')}
|
| 624 |
+
onMinimize={() => minimizeWindow('python')}
|
| 625 |
+
activeWindow={activeWindow}
|
| 626 |
+
setActiveWindow={setActiveWindow}
|
| 627 |
+
defaultWidth="620px"
|
| 628 |
+
defaultHeight="580px"
|
| 629 |
+
titleBarColor="#9d4edd"
|
| 630 |
+
>
|
| 631 |
+
<PythonChallenge />
|
| 632 |
+
</WindowShell>
|
| 633 |
+
|
| 634 |
+
{/* 7. MySQL Playground Window */}
|
| 635 |
+
<WindowShell
|
| 636 |
+
id="mysql"
|
| 637 |
+
title="MySQL Playground"
|
| 638 |
+
icon="database"
|
| 639 |
+
isOpen={windows.mysql.isOpen}
|
| 640 |
+
isMinimized={windows.mysql.isMinimized}
|
| 641 |
+
onClose={() => closeWindow('mysql')}
|
| 642 |
+
onMinimize={() => minimizeWindow('mysql')}
|
| 643 |
+
activeWindow={activeWindow}
|
| 644 |
+
setActiveWindow={setActiveWindow}
|
| 645 |
+
defaultWidth="660px"
|
| 646 |
+
defaultHeight="580px"
|
| 647 |
+
titleBarColor="#0ea5e9"
|
| 648 |
+
>
|
| 649 |
+
<MySQLPlayground />
|
| 650 |
+
</WindowShell>
|
| 651 |
+
|
| 652 |
+
{/* Start Menu Popup */}
|
| 653 |
+
{isStartOpen && (
|
| 654 |
+
<div
|
| 655 |
+
onClick={(e) => e.stopPropagation()}
|
| 656 |
+
className="absolute bottom-14 left-0 w-64 bg-white border-[3px] border-ink-black shadow-[6px_6px_0px_0px_#1E293B] flex flex-col p-4 z-[60] ml-2 mb-2 select-none"
|
| 657 |
+
>
|
| 658 |
+
<div className="flex flex-col gap-2.5 font-window-title text-base font-bold">
|
| 659 |
+
<button
|
| 660 |
+
onClick={() => openWindow('queue')}
|
| 661 |
+
className="w-full text-left hover:bg-secondary-container px-3 py-1.5 border-2 border-transparent hover:border-ink-black transition-colors cursor-pointer flex items-center gap-2"
|
| 662 |
+
>
|
| 663 |
+
<span className="material-symbols-outlined text-lg" style={{ fontVariationSettings: "'FILL' 1" }}>view_list</span>
|
| 664 |
+
Daily Queue
|
| 665 |
+
</button>
|
| 666 |
+
<button
|
| 667 |
+
onClick={() => openWindow('journey')}
|
| 668 |
+
className="w-full text-left hover:bg-secondary-container px-3 py-1.5 border-2 border-transparent hover:border-ink-black transition-colors cursor-pointer flex items-center gap-2"
|
| 669 |
+
>
|
| 670 |
+
<span className="material-symbols-outlined text-lg" style={{ fontVariationSettings: "'FILL' 1" }}>route</span>
|
| 671 |
+
Journey Path
|
| 672 |
+
</button>
|
| 673 |
+
<button
|
| 674 |
+
onClick={triggerSync}
|
| 675 |
+
className="w-full text-left hover:bg-secondary-container px-3 py-1.5 border-2 border-transparent hover:border-ink-black transition-colors cursor-pointer flex items-center gap-2"
|
| 676 |
+
>
|
| 677 |
+
<span className="material-symbols-outlined text-lg" style={{ fontVariationSettings: "'FILL' 1" }}>cloud_sync</span>
|
| 678 |
+
Drive Sync
|
| 679 |
+
</button>
|
| 680 |
+
<button
|
| 681 |
+
onClick={() => openWindow('terminal')}
|
| 682 |
+
className="w-full text-left hover:bg-secondary-container px-3 py-1.5 border-2 border-transparent hover:border-ink-black transition-colors cursor-pointer flex items-center gap-2"
|
| 683 |
+
>
|
| 684 |
+
<span className="material-symbols-outlined text-lg" style={{ fontVariationSettings: "'FILL' 1" }}>terminal</span>
|
| 685 |
+
Terminal
|
| 686 |
+
</button>
|
| 687 |
+
<button
|
| 688 |
+
onClick={() => openWindow('computer')}
|
| 689 |
+
className="w-full text-left hover:bg-secondary-container px-3 py-1.5 border-2 border-transparent hover:border-ink-black transition-colors cursor-pointer flex items-center gap-2"
|
| 690 |
+
>
|
| 691 |
+
<span className="material-symbols-outlined text-lg" style={{ fontVariationSettings: "'FILL' 1" }}>desktop_windows</span>
|
| 692 |
+
My Computer
|
| 693 |
+
</button>
|
| 694 |
+
<button
|
| 695 |
+
onClick={() => openWindow('python')}
|
| 696 |
+
className="w-full text-left hover:bg-secondary-container px-3 py-1.5 border-2 border-transparent hover:border-ink-black transition-colors cursor-pointer flex items-center gap-2"
|
| 697 |
+
>
|
| 698 |
+
<span className="material-symbols-outlined text-lg" style={{ fontVariationSettings: "'FILL' 1" }}>sports_esports</span>
|
| 699 |
+
Python Challenge
|
| 700 |
+
</button>
|
| 701 |
+
<button
|
| 702 |
+
onClick={() => openWindow('mysql')}
|
| 703 |
+
className="w-full text-left hover:bg-secondary-container px-3 py-1.5 border-2 border-transparent hover:border-ink-black transition-colors cursor-pointer flex items-center gap-2"
|
| 704 |
+
>
|
| 705 |
+
<span className="material-symbols-outlined text-lg" style={{ fontVariationSettings: "'FILL' 1" }}>database</span>
|
| 706 |
+
MySQL Playground
|
| 707 |
+
</button>
|
| 708 |
+
</div>
|
| 709 |
+
|
| 710 |
+
<div className="mt-4 flex justify-end border-t-[3px] border-ink-black pt-4 select-none">
|
| 711 |
+
<button
|
| 712 |
+
onClick={handleSignOut}
|
| 713 |
+
className="bg-highlight-pink text-white font-window-title px-4 py-2.5 border-[3px] border-ink-black shadow-[3px_3px_0px_0px_#1E293B] active:translate-y-0.5 active:translate-x-0.5 active:shadow-none hover:bg-rose-600 transition-all cursor-pointer font-bold uppercase text-xs"
|
| 714 |
+
>
|
| 715 |
+
Shut Down
|
| 716 |
+
</button>
|
| 717 |
+
</div>
|
| 718 |
+
</div>
|
| 719 |
+
)}
|
| 720 |
+
|
| 721 |
+
{/* Taskbar Bottom (Taskbar fixed layout) */}
|
| 722 |
+
<div
|
| 723 |
+
onClick={() => setIsStartOpen(false)}
|
| 724 |
+
className="fixed bottom-0 left-0 w-full z-50 flex items-center justify-between h-14 border-t-[4px] border-ink-black bg-[#0ea5e9] px-0 select-none shadow-[0_-4px_0_0_rgba(30,41,59,0.1)]"
|
| 725 |
+
>
|
| 726 |
+
<div className="flex items-center h-full overflow-x-auto custom-scrollbar">
|
| 727 |
+
{/* Start button */}
|
| 728 |
+
<button
|
| 729 |
+
onClick={(e) => {
|
| 730 |
+
e.stopPropagation();
|
| 731 |
+
setIsStartOpen(!isStartOpen);
|
| 732 |
+
}}
|
| 733 |
+
className="h-full px-6 flex items-center gap-2 border-r-[4px] border-ink-black bg-[#39ff14] text-ink-black hover:bg-[#28e007] active:bg-[#1fb304] transition-colors shadow-[inset_-3px_-3px_0px_rgba(0,0,0,0.2),inset_3px_3px_0px_rgba(255,255,255,0.5)] font-bold cursor-pointer"
|
| 734 |
+
>
|
| 735 |
+
<span className="material-symbols-outlined text-2xl font-bold" style={{ fontVariationSettings: "'FILL' 1" }}>
|
| 736 |
+
sports_esports
|
| 737 |
+
</span>
|
| 738 |
+
<span className="font-display-lg text-lg italic tracking-wide">Start</span>
|
| 739 |
+
</button>
|
| 740 |
+
|
| 741 |
+
{/* Windows tabs in taskbar */}
|
| 742 |
+
|
| 743 |
+
{/* Daily Queue Tab */}
|
| 744 |
+
{windows.queue.isOpen && (
|
| 745 |
+
<button
|
| 746 |
+
onClick={() => toggleWindowMinimize('queue')}
|
| 747 |
+
className={`h-full px-4 flex items-center gap-2 border-r-[4px] border-ink-black text-xs font-bold font-window-title cursor-pointer transition-all shadow-[inset_0px_3px_0px_rgba(255,255,255,0.8)] ${
|
| 748 |
+
activeWindow === 'queue' && !windows.queue.isMinimized
|
| 749 |
+
? 'bg-paper-white text-ink-black font-extrabold translate-y-[2px]'
|
| 750 |
+
: 'bg-surface-variant text-trash-gray hover:bg-white/40'
|
| 751 |
+
}`}
|
| 752 |
+
>
|
| 753 |
+
<span className="material-symbols-outlined text-sm font-bold">view_list</span>
|
| 754 |
+
<span>Daily Queue</span>
|
| 755 |
+
</button>
|
| 756 |
+
)}
|
| 757 |
+
|
| 758 |
+
{/* Journey Path Tab */}
|
| 759 |
+
{windows.journey.isOpen && (
|
| 760 |
+
<button
|
| 761 |
+
onClick={() => toggleWindowMinimize('journey')}
|
| 762 |
+
className={`h-full px-4 flex items-center gap-2 border-r-[4px] border-ink-black text-xs font-bold font-window-title cursor-pointer transition-all shadow-[inset_0px_3px_0px_rgba(255,255,255,0.8)] ${
|
| 763 |
+
activeWindow === 'journey' && !windows.journey.isMinimized
|
| 764 |
+
? 'bg-paper-white text-ink-black font-extrabold translate-y-[2px]'
|
| 765 |
+
: 'bg-surface-variant text-trash-gray hover:bg-white/40'
|
| 766 |
+
}`}
|
| 767 |
+
>
|
| 768 |
+
<span className="material-symbols-outlined text-sm font-bold">route</span>
|
| 769 |
+
<span>Journey Path</span>
|
| 770 |
+
</button>
|
| 771 |
+
)}
|
| 772 |
+
|
| 773 |
+
{/* Terminal Tab */}
|
| 774 |
+
{windows.terminal.isOpen && (
|
| 775 |
+
<button
|
| 776 |
+
onClick={() => toggleWindowMinimize('terminal')}
|
| 777 |
+
className={`h-full px-4 flex items-center gap-2 border-r-[4px] border-ink-black text-xs font-bold font-window-title cursor-pointer transition-all shadow-[inset_0px_3px_0px_rgba(255,255,255,0.8)] ${
|
| 778 |
+
activeWindow === 'terminal' && !windows.terminal.isMinimized
|
| 779 |
+
? 'bg-paper-white text-ink-black font-extrabold translate-y-[2px]'
|
| 780 |
+
: 'bg-surface-variant text-trash-gray hover:bg-white/40'
|
| 781 |
+
}`}
|
| 782 |
+
>
|
| 783 |
+
<span className="material-symbols-outlined text-sm font-bold">terminal</span>
|
| 784 |
+
<span>Terminal</span>
|
| 785 |
+
</button>
|
| 786 |
+
)}
|
| 787 |
+
|
| 788 |
+
{/* Google Drive Sync Tab */}
|
| 789 |
+
{windows.sync.isOpen && (
|
| 790 |
+
<button
|
| 791 |
+
onClick={() => toggleWindowMinimize('sync')}
|
| 792 |
+
className={`h-full px-4 flex items-center gap-2 border-r-[4px] border-ink-black text-xs font-bold font-window-title cursor-pointer transition-all shadow-[inset_0px_3px_0px_rgba(255,255,255,0.8)] ${
|
| 793 |
+
activeWindow === 'sync' && !windows.sync.isMinimized
|
| 794 |
+
? 'bg-paper-white text-ink-black font-extrabold translate-y-[2px]'
|
| 795 |
+
: 'bg-surface-variant text-trash-gray hover:bg-white/40'
|
| 796 |
+
}`}
|
| 797 |
+
>
|
| 798 |
+
<span className="material-symbols-outlined text-sm font-bold">cloud_sync</span>
|
| 799 |
+
<span>Drive Sync</span>
|
| 800 |
+
</button>
|
| 801 |
+
)}
|
| 802 |
+
|
| 803 |
+
{/* My Computer Tab */}
|
| 804 |
+
{windows.computer.isOpen && (
|
| 805 |
+
<button
|
| 806 |
+
onClick={() => toggleWindowMinimize('computer')}
|
| 807 |
+
className={`h-full px-4 flex items-center gap-2 border-r-[4px] border-ink-black text-xs font-bold font-window-title cursor-pointer transition-all shadow-[inset_0px_3px_0px_rgba(255,255,255,0.8)] ${
|
| 808 |
+
activeWindow === 'computer' && !windows.computer.isMinimized
|
| 809 |
+
? 'bg-paper-white text-ink-black font-extrabold translate-y-[2px]'
|
| 810 |
+
: 'bg-surface-variant text-trash-gray hover:bg-white/40'
|
| 811 |
+
}`}
|
| 812 |
+
>
|
| 813 |
+
<span className="material-symbols-outlined text-sm font-bold">desktop_windows</span>
|
| 814 |
+
<span>My Computer</span>
|
| 815 |
+
</button>
|
| 816 |
+
)}
|
| 817 |
+
|
| 818 |
+
{/* Python Challenge Tab */}
|
| 819 |
+
{windows.python.isOpen && (
|
| 820 |
+
<button
|
| 821 |
+
onClick={() => toggleWindowMinimize('python')}
|
| 822 |
+
className={`h-full px-4 flex items-center gap-2 border-r-[4px] border-ink-black text-xs font-bold font-window-title cursor-pointer transition-all shadow-[inset_0px_3px_0px_rgba(255,255,255,0.8)] ${
|
| 823 |
+
activeWindow === 'python' && !windows.python.isMinimized
|
| 824 |
+
? 'bg-paper-white text-ink-black font-extrabold translate-y-[2px]'
|
| 825 |
+
: 'bg-surface-variant text-trash-gray hover:bg-white/40'
|
| 826 |
+
}`}
|
| 827 |
+
>
|
| 828 |
+
<span className="material-symbols-outlined text-sm font-bold">sports_esports</span>
|
| 829 |
+
<span>Python Challenge</span>
|
| 830 |
+
</button>
|
| 831 |
+
)}
|
| 832 |
+
|
| 833 |
+
{/* MySQL Playground Tab */}
|
| 834 |
+
{windows.mysql.isOpen && (
|
| 835 |
+
<button
|
| 836 |
+
onClick={() => toggleWindowMinimize('mysql')}
|
| 837 |
+
className={`h-full px-4 flex items-center gap-2 border-r-[4px] border-ink-black text-xs font-bold font-window-title cursor-pointer transition-all shadow-[inset_0px_3px_0px_rgba(255,255,255,0.8)] ${
|
| 838 |
+
activeWindow === 'mysql' && !windows.mysql.isMinimized
|
| 839 |
+
? 'bg-paper-white text-ink-black font-extrabold translate-y-[2px]'
|
| 840 |
+
: 'bg-surface-variant text-trash-gray hover:bg-white/40'
|
| 841 |
+
}`}
|
| 842 |
+
>
|
| 843 |
+
<span className="material-symbols-outlined text-sm font-bold">database</span>
|
| 844 |
+
<span>MySQL Playground</span>
|
| 845 |
+
</button>
|
| 846 |
+
)}
|
| 847 |
+
</div>
|
| 848 |
+
|
| 849 |
+
{/* System tray (wifi, clock, volume) */}
|
| 850 |
+
<div className="flex items-center h-full border-l-[4px] border-ink-black bg-[#0284c7] text-white px-4 gap-4 shadow-[inset_3px_3px_0px_rgba(0,0,0,0.2)]">
|
| 851 |
+
<span className="material-symbols-outlined cursor-pointer select-none text-lg">volume_up</span>
|
| 852 |
+
<span className="material-symbols-outlined cursor-pointer select-none text-lg">network_wifi</span>
|
| 853 |
+
<div className="font-arcade text-[9px] font-bold flex flex-col items-center leading-none justify-center select-none pt-0.5">
|
| 854 |
+
<span>{time}</span>
|
| 855 |
+
</div>
|
| 856 |
+
</div>
|
| 857 |
+
</div>
|
| 858 |
+
|
| 859 |
+
{/* Context Menu (Right Click Options) */}
|
| 860 |
+
{contextMenu.visible && (
|
| 861 |
+
<div
|
| 862 |
+
style={{ top: contextMenu.y, left: contextMenu.x }}
|
| 863 |
+
className="absolute bg-paper-white border-[3px] border-ink-black shadow-[4px_4px_0px_0px_#1E293B] z-[999] py-1 w-48 font-headline-md text-xs font-bold text-ink-black select-none animate-fade-in"
|
| 864 |
+
>
|
| 865 |
+
<button
|
| 866 |
+
onClick={toggleFullscreen}
|
| 867 |
+
className="w-full text-left px-4 py-2.5 hover:bg-secondary-container border-b-[2px] border-dashed border-ink-black flex items-center gap-2 cursor-pointer font-bold select-none outline-none"
|
| 868 |
+
>
|
| 869 |
+
<span className="material-symbols-outlined text-sm">fullscreen</span>
|
| 870 |
+
{isFullscreen ? 'Exit Full Screen' : 'Go Full Screen'}
|
| 871 |
+
</button>
|
| 872 |
+
<button
|
| 873 |
+
onClick={() => {
|
| 874 |
+
closeContextMenu();
|
| 875 |
+
document.getElementById('wallpaper-input')?.click();
|
| 876 |
+
}}
|
| 877 |
+
className="w-full text-left px-4 py-2.5 hover:bg-secondary-container flex items-center gap-2 cursor-pointer font-bold select-none outline-none"
|
| 878 |
+
>
|
| 879 |
+
<span className="material-symbols-outlined text-sm">image</span>
|
| 880 |
+
Change Wallpaper
|
| 881 |
+
</button>
|
| 882 |
+
</div>
|
| 883 |
+
)}
|
| 884 |
+
|
| 885 |
+
{/* Hidden Wallpaper File Input Selector */}
|
| 886 |
+
<input
|
| 887 |
+
type="file"
|
| 888 |
+
id="wallpaper-input"
|
| 889 |
+
accept="image/*"
|
| 890 |
+
onChange={handleWallpaperChange}
|
| 891 |
+
className="hidden"
|
| 892 |
+
/>
|
| 893 |
+
</div>
|
| 894 |
+
);
|
| 895 |
+
}
|
algospaced-ui/src/assets/react.svg
ADDED
|
|
algospaced-ui/src/assets/vite.svg
ADDED
|
|
algospaced-ui/src/components/Auth.tsx
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from 'react';
|
| 2 |
+
import { supabase } from '../supabaseClient';
|
| 3 |
+
import { Mail, Lock, ShieldCheck, ArrowRight } from 'lucide-react';
|
| 4 |
+
|
| 5 |
+
export default function Auth() {
|
| 6 |
+
const [isSignUp, setIsSignUp] = useState(false);
|
| 7 |
+
const [email, setEmail] = useState('');
|
| 8 |
+
const [password, setPassword] = useState('');
|
| 9 |
+
const [loading, setLoading] = useState(false);
|
| 10 |
+
const [message, setMessage] = useState<{ type: 'error' | 'success'; text: string } | null>(null);
|
| 11 |
+
|
| 12 |
+
const handleAuth = async (e: React.FormEvent) => {
|
| 13 |
+
e.preventDefault();
|
| 14 |
+
setLoading(true);
|
| 15 |
+
setMessage(null);
|
| 16 |
+
|
| 17 |
+
try {
|
| 18 |
+
if (isSignUp) {
|
| 19 |
+
const { error } = await supabase.auth.signUp({
|
| 20 |
+
email,
|
| 21 |
+
password,
|
| 22 |
+
});
|
| 23 |
+
if (error) throw error;
|
| 24 |
+
setMessage({ type: 'success', text: 'Registration successful! Check your email for validation link.' });
|
| 25 |
+
} else {
|
| 26 |
+
const { error } = await supabase.auth.signInWithPassword({
|
| 27 |
+
email,
|
| 28 |
+
password,
|
| 29 |
+
});
|
| 30 |
+
if (error) throw error;
|
| 31 |
+
}
|
| 32 |
+
} catch (err: any) {
|
| 33 |
+
setMessage({ type: 'error', text: err.message || 'An error occurred during authentication' });
|
| 34 |
+
} finally {
|
| 35 |
+
setLoading(false);
|
| 36 |
+
}
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
const handleGoogleSignIn = async () => {
|
| 40 |
+
try {
|
| 41 |
+
setLoading(true);
|
| 42 |
+
const redirectUrl = window.location.origin.endsWith('/') ? window.location.origin : `${window.location.origin}/`;
|
| 43 |
+
alert('Sending redirect URL: ' + redirectUrl);
|
| 44 |
+
const { error } = await supabase.auth.signInWithOAuth({
|
| 45 |
+
provider: 'google',
|
| 46 |
+
options: {
|
| 47 |
+
redirectTo: redirectUrl
|
| 48 |
+
}
|
| 49 |
+
});
|
| 50 |
+
if (error) throw error;
|
| 51 |
+
} catch (err: any) {
|
| 52 |
+
setMessage({ type: 'error', text: err.message || 'Google OAuth failed to initialize' });
|
| 53 |
+
setLoading(false);
|
| 54 |
+
}
|
| 55 |
+
};
|
| 56 |
+
|
| 57 |
+
return (
|
| 58 |
+
<div className="min-h-screen w-screen flex items-center justify-center p-4 relative selection:bg-highlight-pink selection:text-white">
|
| 59 |
+
{/* Background Wallpaper image */}
|
| 60 |
+
<div
|
| 61 |
+
className="absolute inset-0 w-full h-full bg-cover bg-center z-0 saturate-150 contrast-125"
|
| 62 |
+
style={{ backgroundImage: "url('https://lh3.googleusercontent.com/aida-public/AB6AXuAMJHVhhK8_G7F8sbhn8F7w4AZWXb1O_-HLKvrZ_fJlbUVMcVuxxEOO_LlOh8qIwtAn2KvzvCwzmtNLLEZ5uYkCEsx8ZWXJR609qgSMRSX8LBBeskk4VzVXnyzsgIKCedeV2PvGxJlUNnledcWKCXqG9egQi8dgTcA7C2z82QyM73KZ4s7ZRzZHNupuQt1ocfcMl9E_x1PWFRridl751LIpyGZgednS5CmVw2rZvFc_tbp2QTxgVHJ_59myEBmy6aajbO06AhkKmCvI')" }}
|
| 63 |
+
/>
|
| 64 |
+
|
| 65 |
+
{/* OS System Setup Dialog Box */}
|
| 66 |
+
<div className="w-full max-w-md bg-paper-white border-[4px] border-ink-black window-shadow z-10 flex flex-col overflow-hidden shadow-[inset_2px_2px_0px_rgba(255,255,255,0.8)] relative animate-float">
|
| 67 |
+
{/* Title Bar */}
|
| 68 |
+
<div className="bg-[#0ea5e9] border-b-[4px] border-ink-black px-4 py-2.5 flex justify-between items-center text-white select-none">
|
| 69 |
+
<div className="flex items-center gap-2">
|
| 70 |
+
<span className="material-symbols-outlined select-none" style={{ fontVariationSettings: "'FILL' 1" }}>
|
| 71 |
+
sports_esports
|
| 72 |
+
</span>
|
| 73 |
+
<span className="font-window-title text-sm md:text-base font-bold tracking-wide">System_Setup.exe</span>
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
|
| 77 |
+
{/* Content Box */}
|
| 78 |
+
<div className="p-6 bg-[#fdf8e1] flex-1 flex flex-col">
|
| 79 |
+
<div className="flex flex-col items-center mb-6">
|
| 80 |
+
<div className="w-16 h-16 bg-paper-white border-[3px] border-ink-black flex items-center justify-center gadget-shadow shadow-[inset_2px_2px_0px_rgba(255,255,255,0.8)] mb-4">
|
| 81 |
+
<ShieldCheck className="w-9 h-9 text-primary" />
|
| 82 |
+
</div>
|
| 83 |
+
<h2 className="text-3xl font-extrabold tracking-tight text-ink-black font-headline-md leading-none m-0">AlgoSpaced</h2>
|
| 84 |
+
<p className="text-xs font-arcade text-trash-gray mt-2 tracking-wide uppercase text-center">Spaced Repetition for DSA</p>
|
| 85 |
+
</div>
|
| 86 |
+
|
| 87 |
+
{/* Form Tabs */}
|
| 88 |
+
<div className="flex gap-2 mb-6 font-arcade text-[10px]">
|
| 89 |
+
<button
|
| 90 |
+
onClick={() => { setIsSignUp(false); setMessage(null); }}
|
| 91 |
+
className={`flex-1 py-2.5 border-[3px] border-ink-black cursor-pointer font-bold tracking-wider outline-none transition-all shadow-[2px_2px_0px_0px_rgba(30,41,59,1)] active:shadow-none active:translate-y-0.5 ${
|
| 92 |
+
!isSignUp
|
| 93 |
+
? 'bg-secondary-container text-ink-black translate-y-0.5 shadow-[1px_1px_0px_0px_rgba(30,41,59,1)]'
|
| 94 |
+
: 'bg-white text-trash-gray hover:bg-surface-container'
|
| 95 |
+
}`}
|
| 96 |
+
>
|
| 97 |
+
SIGN IN
|
| 98 |
+
</button>
|
| 99 |
+
<button
|
| 100 |
+
onClick={() => { setIsSignUp(true); setMessage(null); }}
|
| 101 |
+
className={`flex-1 py-2.5 border-[3px] border-ink-black cursor-pointer font-bold tracking-wider outline-none transition-all shadow-[2px_2px_0px_0px_rgba(30,41,59,1)] active:shadow-none active:translate-y-0.5 ${
|
| 102 |
+
isSignUp
|
| 103 |
+
? 'bg-secondary-container text-ink-black translate-y-0.5 shadow-[1px_1px_0px_0px_rgba(30,41,59,1)]'
|
| 104 |
+
: 'bg-white text-trash-gray hover:bg-surface-container'
|
| 105 |
+
}`}
|
| 106 |
+
>
|
| 107 |
+
CREATE COMP
|
| 108 |
+
</button>
|
| 109 |
+
</div>
|
| 110 |
+
|
| 111 |
+
{message && (
|
| 112 |
+
<div
|
| 113 |
+
className={`mb-6 p-4 border-[3px] border-ink-black text-xs font-arcade leading-relaxed ${
|
| 114 |
+
message.type === 'error'
|
| 115 |
+
? 'bg-red-100 border-red-500 text-error shadow-[2px_2px_0px_0px_rgba(30,41,59,1)]'
|
| 116 |
+
: 'bg-emerald-100 border-emerald-500 text-emerald-700 shadow-[2px_2px_0px_0px_rgba(30,41,59,1)]'
|
| 117 |
+
}`}
|
| 118 |
+
>
|
| 119 |
+
{message.text}
|
| 120 |
+
</div>
|
| 121 |
+
)}
|
| 122 |
+
|
| 123 |
+
<form onSubmit={handleAuth} className="space-y-4">
|
| 124 |
+
<div>
|
| 125 |
+
<label className="block text-xs font-bold text-ink-black uppercase tracking-wider mb-2 font-headline-md">Email Address</label>
|
| 126 |
+
<div className="relative">
|
| 127 |
+
<Mail className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-ink-black" />
|
| 128 |
+
<input
|
| 129 |
+
type="email"
|
| 130 |
+
required
|
| 131 |
+
value={email}
|
| 132 |
+
onChange={(e) => setEmail(e.target.value)}
|
| 133 |
+
placeholder="name@arcade.com"
|
| 134 |
+
className="w-full bg-white border-[3px] border-ink-black rounded-lg py-2.5 pl-10 pr-4 text-ink-black placeholder-trash-gray focus:outline-none focus:bg-white text-sm font-code-mono shadow-[inset_2px_2px_0px_rgba(0,0,0,0.1)]"
|
| 135 |
+
/>
|
| 136 |
+
</div>
|
| 137 |
+
</div>
|
| 138 |
+
|
| 139 |
+
<div>
|
| 140 |
+
<label className="block text-xs font-bold text-ink-black uppercase tracking-wider mb-2 font-headline-md">Password</label>
|
| 141 |
+
<div className="relative">
|
| 142 |
+
<Lock className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-ink-black" />
|
| 143 |
+
<input
|
| 144 |
+
type="password"
|
| 145 |
+
required
|
| 146 |
+
value={password}
|
| 147 |
+
onChange={(e) => setPassword(e.target.value)}
|
| 148 |
+
placeholder="••••••••"
|
| 149 |
+
className="w-full bg-white border-[3px] border-ink-black rounded-lg py-2.5 pl-10 pr-4 text-ink-black placeholder-trash-gray focus:outline-none focus:bg-white text-sm font-code-mono shadow-[inset_2px_2px_0px_rgba(0,0,0,0.1)]"
|
| 150 |
+
/>
|
| 151 |
+
</div>
|
| 152 |
+
</div>
|
| 153 |
+
|
| 154 |
+
<button
|
| 155 |
+
type="submit"
|
| 156 |
+
disabled={loading}
|
| 157 |
+
className="w-full bg-[#ffcc00] border-[4px] border-ink-black rounded-xl py-3.5 font-window-title text-base text-ink-black arcade-btn cursor-pointer disabled:opacity-50 flex items-center justify-center gap-2 uppercase font-bold tracking-wide"
|
| 158 |
+
>
|
| 159 |
+
{loading ? 'RUNNING...' : isSignUp ? 'RUN SETUP.EXE' : 'EXEC LOGON'}
|
| 160 |
+
{!loading && <ArrowRight className="w-4 h-4 stroke-[3px]" />}
|
| 161 |
+
</button>
|
| 162 |
+
</form>
|
| 163 |
+
|
| 164 |
+
<div className="relative my-5 select-none">
|
| 165 |
+
<div className="absolute inset-0 flex items-center"><div className="w-full border-t-[3px] border-ink-black border-dashed" /></div>
|
| 166 |
+
<div className="relative flex justify-center text-[10px] uppercase font-arcade"><span className="bg-[#fdf8e1] px-3 text-trash-gray font-bold">OR LOAD ALTERNATE</span></div>
|
| 167 |
+
</div>
|
| 168 |
+
|
| 169 |
+
<button
|
| 170 |
+
onClick={handleGoogleSignIn}
|
| 171 |
+
disabled={loading}
|
| 172 |
+
className="w-full bg-white hover:bg-slate-100 border-[3px] border-ink-black text-ink-black font-bold py-3 px-4 rounded-xl flex items-center justify-center gap-3 transition-all text-xs cursor-pointer shadow-[3px_3px_0px_0px_rgba(30,41,59,1)] active:translate-y-0.5 active:shadow-none"
|
| 173 |
+
>
|
| 174 |
+
<svg className="w-4 h-4 shrink-0" viewBox="0 0 24 24" fill="currentColor">
|
| 175 |
+
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4" />
|
| 176 |
+
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
|
| 177 |
+
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.06H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.94l2.85-2.22.81-.63z" fill="#FBBC05" />
|
| 178 |
+
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.06l3.66 2.84c.87-2.6 3.3-4.52 6.16-4.52z" fill="#EA4335" />
|
| 179 |
+
</svg>
|
| 180 |
+
SIGN IN WITH GOOGLE
|
| 181 |
+
</button>
|
| 182 |
+
</div>
|
| 183 |
+
</div>
|
| 184 |
+
</div>
|
| 185 |
+
);
|
| 186 |
+
}
|
algospaced-ui/src/components/FileExplorer.tsx
ADDED
|
@@ -0,0 +1,948 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { supabase } from '../supabaseClient';
|
| 3 |
+
|
| 4 |
+
interface ProblemFile {
|
| 5 |
+
id: string;
|
| 6 |
+
name: string;
|
| 7 |
+
pattern: string;
|
| 8 |
+
difficulty: string;
|
| 9 |
+
reference_code: string;
|
| 10 |
+
description: string | null;
|
| 11 |
+
box_level: number | null;
|
| 12 |
+
next_review: string | null;
|
| 13 |
+
last_reviewed: string | null;
|
| 14 |
+
times_correct: number;
|
| 15 |
+
total_attempts: number;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
interface FileExplorerProps {
|
| 19 |
+
onSetWallpaper: (url: string) => void;
|
| 20 |
+
activeWallpaperUrl: string;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
const WALLPAPER_PRESETS = [
|
| 24 |
+
{
|
| 25 |
+
name: 'Retro Arcade Grid.img',
|
| 26 |
+
url: 'https://lh3.googleusercontent.com/aida-public/AB6AXuAMJHVhhK8_G7F8sbhn8F7w4AZWXb1O_-HLKvrZ_fJlbUVMcVuxxEOO_LlOh8qIwtAn2KvzvCwzmtNLLEZ5uYkCEsx8ZWXJR609qgSMRSX8LBBeskk4VzVXnyzsgIKCedeV2PvGxJlUNnledcWKCXqG9egQi8dgTcA7C2z82QyM73KZ4s7ZRzZHNupuQt1ocfcMl9E_x1PWFRridl751LIpyGZgednS5CmVw2rZvFc_tbp2QTxgVHJ_59myEBmy6aajbO06AhkKmCvI',
|
| 27 |
+
desc: 'Default AlgoSpaced purple grid and pixel theme background.'
|
| 28 |
+
},
|
| 29 |
+
{
|
| 30 |
+
name: 'Classic Board Logo.img',
|
| 31 |
+
url: 'https://lh3.googleusercontent.com/aida-public/AB6AXuD2tUGA2d7QiNyajDAWYK183zhNTgtAZpOXK3E9wRvHGs-t6ykWt9uPScYe_fziWzQI0pREcY_ThI341WvGMusyYmnAkagfuwx6wubIs1ES68DO8CCNAlcHcb2zOUU4MJeYuhWDy1uYRqyGYjIaDUqfgNWk2vm4WzwRqoorn2dxtZ6QdJhEKbXjG8fG9chkkvr68qJf0fLUL6bIqBXZg2FJ30S7zS3oZ4IZsug-wLyRYuJ4Gu_86snNUo1whNrBdZm5OMREfc9sLbwJ',
|
| 32 |
+
desc: 'System info and arcade machine diagram wallpaper.'
|
| 33 |
+
},
|
| 34 |
+
{
|
| 35 |
+
name: 'Terminal Console Icon.img',
|
| 36 |
+
url: 'https://lh3.googleusercontent.com/aida-public/AB6AXuA2AvcQ2oKWP8AJ8yR5nF82LLDEH1FKySUK-npnqEjvJ6Cs9xp8joCwH7x43oTCAC5xhO2rQdYzka3B6u72FNbdxMnsv3WJLye3bKochu8AnnRlS3szfYSaj724sYsiZo4n8G-oWqvl7C0rOGaDaU2nRH1xXmW2PBi6L2NryTsUaTCVZQKTKBBNNEOig5kh7B52e-iIuj2PTzaSjF9ebp6gMuCnBtJT8Sxo5Qsmvfm-t2U5r_qQpRiYj--Ut41T7NTh-tHgs4S4kBvz',
|
| 37 |
+
desc: 'Dark retro CRT terminal screen layout.'
|
| 38 |
+
},
|
| 39 |
+
{
|
| 40 |
+
name: 'Synthwave Sunset Grid.img',
|
| 41 |
+
url: 'https://images.unsplash.com/photo-1550745165-9bc0b252726f?q=80&w=1000&auto=format&fit=crop',
|
| 42 |
+
desc: 'Glowing neon grids and vector mountains styling.'
|
| 43 |
+
},
|
| 44 |
+
{
|
| 45 |
+
name: 'Matrix Code Rain.img',
|
| 46 |
+
url: 'https://images.unsplash.com/photo-1526374965328-7f61d4dc18c5?q=80&w=1000&auto=format&fit=crop',
|
| 47 |
+
desc: 'Falling green digital characters rain.'
|
| 48 |
+
}
|
| 49 |
+
];
|
| 50 |
+
|
| 51 |
+
export default function FileExplorer({ onSetWallpaper, activeWallpaperUrl }: FileExplorerProps) {
|
| 52 |
+
const [currentPath, setCurrentPath] = useState<string[]>(['C:']);
|
| 53 |
+
const [problems, setProblems] = useState<ProblemFile[]>([]);
|
| 54 |
+
const [loading, setLoading] = useState(true);
|
| 55 |
+
const [selectedItem, setSelectedItem] = useState<string | null>(null);
|
| 56 |
+
|
| 57 |
+
// Dynamic wallpapers list merging presets and custom wallpaper from localStorage
|
| 58 |
+
const wallpapersList = React.useMemo(() => {
|
| 59 |
+
const list = [...WALLPAPER_PRESETS];
|
| 60 |
+
const customUrl = localStorage.getItem('customWallpaperUrl');
|
| 61 |
+
const customName = localStorage.getItem('customWallpaperName') || 'Custom Wallpaper.img';
|
| 62 |
+
if (customUrl) {
|
| 63 |
+
const exists = list.some(p => p.url === customUrl);
|
| 64 |
+
if (!exists) {
|
| 65 |
+
list.push({
|
| 66 |
+
name: customName,
|
| 67 |
+
url: customUrl,
|
| 68 |
+
desc: 'User uploaded custom background wallpaper.'
|
| 69 |
+
});
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
return list;
|
| 73 |
+
}, [activeWallpaperUrl]);
|
| 74 |
+
|
| 75 |
+
// Search & Filter state
|
| 76 |
+
const [searchQuery, setSearchQuery] = useState('');
|
| 77 |
+
const [difficultyFilter, setDifficultyFilter] = useState('All');
|
| 78 |
+
const [patternFilter, setPatternFilter] = useState('All');
|
| 79 |
+
|
| 80 |
+
// Drag & Drop state
|
| 81 |
+
const [dragOver, setDragOver] = useState(false);
|
| 82 |
+
|
| 83 |
+
// New File Creation state
|
| 84 |
+
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
| 85 |
+
const [newFileName, setNewFileName] = useState('');
|
| 86 |
+
const [newFilePattern, setNewFilePattern] = useState('');
|
| 87 |
+
const [newFileDifficulty, setNewFileDifficulty] = useState('Medium');
|
| 88 |
+
const [newFileCode, setNewFileCode] = useState('');
|
| 89 |
+
const [newFileDescription, setNewFileDescription] = useState('');
|
| 90 |
+
const [createError, setCreateError] = useState('');
|
| 91 |
+
const [creating, setCreating] = useState(false);
|
| 92 |
+
|
| 93 |
+
// Previewer Modal state
|
| 94 |
+
const [previewFile, setPreviewFile] = useState<ProblemFile | null>(null);
|
| 95 |
+
const [modalTab, setModalTab] = useState<'description' | 'code' | 'properties'>('description');
|
| 96 |
+
const [isEditing, setIsEditing] = useState(false);
|
| 97 |
+
const [editedCode, setEditedCode] = useState('');
|
| 98 |
+
const [editedDescription, setEditedDescription] = useState('');
|
| 99 |
+
const [saving, setSaving] = useState(false);
|
| 100 |
+
const [saveSuccess, setSaveSuccess] = useState(false);
|
| 101 |
+
const [copied, setCopied] = useState(false);
|
| 102 |
+
|
| 103 |
+
const fetchProblems = async () => {
|
| 104 |
+
setLoading(true);
|
| 105 |
+
try {
|
| 106 |
+
const { data: { session } } = await supabase.auth.getSession();
|
| 107 |
+
if (!session) return;
|
| 108 |
+
|
| 109 |
+
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/explorer/problems`, {
|
| 110 |
+
headers: { 'Authorization': `Bearer ${session.access_token}` }
|
| 111 |
+
});
|
| 112 |
+
if (res.ok) {
|
| 113 |
+
const data = await res.json();
|
| 114 |
+
setProblems(data);
|
| 115 |
+
}
|
| 116 |
+
} catch (err) {
|
| 117 |
+
console.error('Failed to fetch explorer files:', err);
|
| 118 |
+
} finally {
|
| 119 |
+
setLoading(false);
|
| 120 |
+
}
|
| 121 |
+
};
|
| 122 |
+
|
| 123 |
+
useEffect(() => {
|
| 124 |
+
fetchProblems();
|
| 125 |
+
}, []);
|
| 126 |
+
|
| 127 |
+
const navigateTo = (folder: string) => {
|
| 128 |
+
setCurrentPath(['C:', folder]);
|
| 129 |
+
setSelectedItem(null);
|
| 130 |
+
};
|
| 131 |
+
|
| 132 |
+
const navigateUp = () => {
|
| 133 |
+
if (currentPath.length > 1) {
|
| 134 |
+
setCurrentPath(['C:']);
|
| 135 |
+
setSelectedItem(null);
|
| 136 |
+
}
|
| 137 |
+
};
|
| 138 |
+
|
| 139 |
+
// ZIP Solutions Exporter via Backend stream
|
| 140 |
+
const handleExportZip = async () => {
|
| 141 |
+
try {
|
| 142 |
+
const { data: { session } } = await supabase.auth.getSession();
|
| 143 |
+
if (!session) return;
|
| 144 |
+
|
| 145 |
+
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/explorer/export`, {
|
| 146 |
+
headers: { 'Authorization': `Bearer ${session.access_token}` }
|
| 147 |
+
});
|
| 148 |
+
if (res.ok) {
|
| 149 |
+
const blob = await res.blob();
|
| 150 |
+
const url = window.URL.createObjectURL(blob);
|
| 151 |
+
const a = document.createElement('a');
|
| 152 |
+
a.href = url;
|
| 153 |
+
a.download = 'algospaced_solutions.zip';
|
| 154 |
+
document.body.appendChild(a);
|
| 155 |
+
a.click();
|
| 156 |
+
document.body.removeChild(a);
|
| 157 |
+
window.URL.revokeObjectURL(url);
|
| 158 |
+
}
|
| 159 |
+
} catch (err) {
|
| 160 |
+
console.error('Failed to download solutions ZIP:', err);
|
| 161 |
+
}
|
| 162 |
+
};
|
| 163 |
+
|
| 164 |
+
// Handle Save Solution Code changes
|
| 165 |
+
const handleSaveSolution = async () => {
|
| 166 |
+
if (!previewFile) return;
|
| 167 |
+
setSaving(true);
|
| 168 |
+
setSaveSuccess(false);
|
| 169 |
+
try {
|
| 170 |
+
const { data: { session } } = await supabase.auth.getSession();
|
| 171 |
+
if (!session) return;
|
| 172 |
+
|
| 173 |
+
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/explorer/problems/${previewFile.id}`, {
|
| 174 |
+
method: 'PUT',
|
| 175 |
+
headers: {
|
| 176 |
+
'Content-Type': 'application/json',
|
| 177 |
+
'Authorization': `Bearer ${session.access_token}`
|
| 178 |
+
},
|
| 179 |
+
body: JSON.stringify({
|
| 180 |
+
name: previewFile.name,
|
| 181 |
+
pattern: previewFile.pattern,
|
| 182 |
+
difficulty: previewFile.difficulty,
|
| 183 |
+
reference_code: editedCode,
|
| 184 |
+
description: editedDescription
|
| 185 |
+
})
|
| 186 |
+
});
|
| 187 |
+
if (res.ok) {
|
| 188 |
+
setSaveSuccess(true);
|
| 189 |
+
setIsEditing(false);
|
| 190 |
+
// Refresh local problems list
|
| 191 |
+
const updated = problems.map(p => p.id === previewFile.id ? { ...p, reference_code: editedCode, description: editedDescription } : p);
|
| 192 |
+
setProblems(updated);
|
| 193 |
+
setPreviewFile({ ...previewFile, reference_code: editedCode, description: editedDescription });
|
| 194 |
+
setTimeout(() => setSaveSuccess(false), 2000);
|
| 195 |
+
}
|
| 196 |
+
} catch (err) {
|
| 197 |
+
console.error('Failed to update solution code:', err);
|
| 198 |
+
} finally {
|
| 199 |
+
setSaving(false);
|
| 200 |
+
}
|
| 201 |
+
};
|
| 202 |
+
|
| 203 |
+
// Create New Problem
|
| 204 |
+
const handleCreateProblem = async (e: React.FormEvent) => {
|
| 205 |
+
e.preventDefault();
|
| 206 |
+
if (!newFileName.trim()) {
|
| 207 |
+
setCreateError('Problem name cannot be empty');
|
| 208 |
+
return;
|
| 209 |
+
}
|
| 210 |
+
setCreating(true);
|
| 211 |
+
setCreateError('');
|
| 212 |
+
try {
|
| 213 |
+
const { data: { session } } = await supabase.auth.getSession();
|
| 214 |
+
if (!session) return;
|
| 215 |
+
|
| 216 |
+
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/explorer/problems/create`, {
|
| 217 |
+
method: 'POST',
|
| 218 |
+
headers: {
|
| 219 |
+
'Content-Type': 'application/json',
|
| 220 |
+
'Authorization': `Bearer ${session.access_token}`
|
| 221 |
+
},
|
| 222 |
+
body: JSON.stringify({
|
| 223 |
+
name: newFileName.trim(),
|
| 224 |
+
pattern: newFilePattern.trim() || 'General',
|
| 225 |
+
difficulty: newFileDifficulty,
|
| 226 |
+
reference_code: newFileCode,
|
| 227 |
+
description: newFileDescription
|
| 228 |
+
})
|
| 229 |
+
});
|
| 230 |
+
|
| 231 |
+
const data = await res.json();
|
| 232 |
+
if (res.ok && data.success) {
|
| 233 |
+
setIsCreateModalOpen(false);
|
| 234 |
+
setNewFileName('');
|
| 235 |
+
setNewFilePattern('');
|
| 236 |
+
setNewFileCode('');
|
| 237 |
+
setNewFileDescription('');
|
| 238 |
+
fetchProblems();
|
| 239 |
+
} else {
|
| 240 |
+
setCreateError(data.detail || 'Failed to create problem.');
|
| 241 |
+
}
|
| 242 |
+
} catch {
|
| 243 |
+
setCreateError('Connection timed out.');
|
| 244 |
+
} finally {
|
| 245 |
+
setCreating(false);
|
| 246 |
+
}
|
| 247 |
+
};
|
| 248 |
+
|
| 249 |
+
// Drag and Drop files parser
|
| 250 |
+
const handleDragOver = (e: React.DragEvent) => {
|
| 251 |
+
e.preventDefault();
|
| 252 |
+
setDragOver(true);
|
| 253 |
+
};
|
| 254 |
+
|
| 255 |
+
const handleDragLeave = () => {
|
| 256 |
+
setDragOver(false);
|
| 257 |
+
};
|
| 258 |
+
|
| 259 |
+
const handleDrop = async (e: React.DragEvent) => {
|
| 260 |
+
e.preventDefault();
|
| 261 |
+
setDragOver(false);
|
| 262 |
+
const files = e.dataTransfer.files;
|
| 263 |
+
if (files.length > 0) {
|
| 264 |
+
const file = files[0];
|
| 265 |
+
if (file.name.endsWith('.py')) {
|
| 266 |
+
try {
|
| 267 |
+
const text = await file.text();
|
| 268 |
+
const guessedName = file.name
|
| 269 |
+
.replace('.py', '')
|
| 270 |
+
.replace(/_/g, ' ')
|
| 271 |
+
.replace(/-/g, ' ');
|
| 272 |
+
|
| 273 |
+
// Parse metadata tags if they exist inside script comments
|
| 274 |
+
let pattern = 'General';
|
| 275 |
+
let difficulty = 'Medium';
|
| 276 |
+
|
| 277 |
+
const patternMatch = text.match(/#\s*Pattern:\s*(.+)/i);
|
| 278 |
+
if (patternMatch) pattern = patternMatch[1].trim();
|
| 279 |
+
|
| 280 |
+
const difficultyMatch = text.match(/#\s*Difficulty:\s*(.+)/i);
|
| 281 |
+
if (difficultyMatch) {
|
| 282 |
+
const val = difficultyMatch[1].trim();
|
| 283 |
+
if (['Easy', 'Medium', 'Hard'].includes(val)) difficulty = val;
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
// Parse description: take all leading comment lines (excluding structural tags)
|
| 287 |
+
const lines = text.split('\n');
|
| 288 |
+
let descLines = [];
|
| 289 |
+
for (let line of lines) {
|
| 290 |
+
const trimmed = line.trim();
|
| 291 |
+
if (trimmed.startsWith('#')) {
|
| 292 |
+
if (trimmed.match(/#\s*(Pattern|Difficulty|Problem|Description):/i)) continue;
|
| 293 |
+
descLines.push(trimmed.replace(/^#\s*/, ''));
|
| 294 |
+
} else if (trimmed !== '') {
|
| 295 |
+
break;
|
| 296 |
+
}
|
| 297 |
+
}
|
| 298 |
+
const description = descLines.join('\n').trim();
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
// Load into New File Creator modal
|
| 303 |
+
setNewFileName(guessedName);
|
| 304 |
+
setNewFilePattern(pattern);
|
| 305 |
+
setNewFileDifficulty(difficulty);
|
| 306 |
+
setNewFileCode(text);
|
| 307 |
+
setNewFileDescription(description);
|
| 308 |
+
setIsCreateModalOpen(true);
|
| 309 |
+
} catch (err) {
|
| 310 |
+
console.error('Failed to parse uploaded script:', err);
|
| 311 |
+
}
|
| 312 |
+
}
|
| 313 |
+
}
|
| 314 |
+
};
|
| 315 |
+
|
| 316 |
+
const handleCopyCode = (text: string) => {
|
| 317 |
+
navigator.clipboard.writeText(text);
|
| 318 |
+
setCopied(true);
|
| 319 |
+
setTimeout(() => setCopied(false), 2000);
|
| 320 |
+
};
|
| 321 |
+
|
| 322 |
+
const activeFolder = currentPath.length > 1 ? currentPath[1] : 'root';
|
| 323 |
+
|
| 324 |
+
// Statistics calculation for the visualizer
|
| 325 |
+
const totalProblems = problems.length;
|
| 326 |
+
const completedSolutions = problems.filter(p => p.reference_code && p.reference_code.trim()).length;
|
| 327 |
+
const completionPercent = totalProblems > 0 ? Math.round((completedSolutions / totalProblems) * 100) : 0;
|
| 328 |
+
|
| 329 |
+
const boxCounts = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
|
| 330 |
+
problems.forEach(p => {
|
| 331 |
+
if (p.box_level !== null && p.box_level !== undefined) {
|
| 332 |
+
const lvl = p.box_level as 1|2|3|4|5;
|
| 333 |
+
if (lvl >= 1 && lvl <= 5) boxCounts[lvl]++;
|
| 334 |
+
}
|
| 335 |
+
});
|
| 336 |
+
|
| 337 |
+
// Extract unique patterns to populate filter dropdown dynamically
|
| 338 |
+
const uniquePatterns = Array.from(new Set(problems.map(p => p.pattern).filter(Boolean)));
|
| 339 |
+
|
| 340 |
+
// Filter solutions list
|
| 341 |
+
const filteredProblems = problems.filter(p => {
|
| 342 |
+
const matchesSearch = p.name.toLowerCase().includes(searchQuery.toLowerCase());
|
| 343 |
+
const matchesDifficulty = difficultyFilter === 'All' || p.difficulty === difficultyFilter;
|
| 344 |
+
const matchesPattern = patternFilter === 'All' || p.pattern === patternFilter;
|
| 345 |
+
return matchesSearch && matchesDifficulty && matchesPattern;
|
| 346 |
+
});
|
| 347 |
+
|
| 348 |
+
return (
|
| 349 |
+
<div className="w-full h-full bg-paper-white text-ink-black flex flex-col overflow-hidden text-xs md:text-sm font-sans select-none">
|
| 350 |
+
|
| 351 |
+
{/* 1. Address Bar & Navigation */}
|
| 352 |
+
<div className="bg-slate-200 border-b-[3px] border-ink-black p-2 flex items-center justify-between shrink-0">
|
| 353 |
+
<div className="flex items-center gap-2 flex-1">
|
| 354 |
+
<button
|
| 355 |
+
onClick={navigateUp}
|
| 356 |
+
disabled={currentPath.length === 1}
|
| 357 |
+
className="w-7 h-7 bg-white border-2 border-ink-black flex items-center justify-center hover:bg-slate-100 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer active:translate-y-0.5"
|
| 358 |
+
title="Up One Folder"
|
| 359 |
+
>
|
| 360 |
+
<span className="material-symbols-outlined text-[16px] font-bold">arrow_upward</span>
|
| 361 |
+
</button>
|
| 362 |
+
<div className="bg-white border-2 border-ink-black px-3 py-1 font-mono text-[10px] flex-1 max-w-[280px] truncate">
|
| 363 |
+
{currentPath.join('\\')}
|
| 364 |
+
</div>
|
| 365 |
+
</div>
|
| 366 |
+
|
| 367 |
+
{/* Action Link bar */}
|
| 368 |
+
<div className="flex gap-3 text-[10px] font-bold pr-2 font-mono items-center">
|
| 369 |
+
{activeFolder === 'Database_Codes' && (
|
| 370 |
+
<>
|
| 371 |
+
<button
|
| 372 |
+
onClick={() => setIsCreateModalOpen(true)}
|
| 373 |
+
className="text-blue-700 hover:underline cursor-pointer flex items-center gap-0.5"
|
| 374 |
+
>
|
| 375 |
+
<span className="material-symbols-outlined text-[12px] font-bold">add_box</span>
|
| 376 |
+
NEW FILE
|
| 377 |
+
</button>
|
| 378 |
+
<span className="text-slate-400">|</span>
|
| 379 |
+
<button
|
| 380 |
+
onClick={handleExportZip}
|
| 381 |
+
className="text-purple-700 hover:underline cursor-pointer flex items-center gap-0.5"
|
| 382 |
+
>
|
| 383 |
+
<span className="material-symbols-outlined text-[12px] font-bold">downloadzip</span>
|
| 384 |
+
BACKUP (.ZIP)
|
| 385 |
+
</button>
|
| 386 |
+
<span className="text-slate-400">|</span>
|
| 387 |
+
</>
|
| 388 |
+
)}
|
| 389 |
+
<span className="text-slate-500">
|
| 390 |
+
{activeFolder === 'root' ? '2 Folder(s)' : activeFolder === 'Database_Codes' ? `${filteredProblems.length} of ${problems.length} item(s)` : `${wallpapersList.length} image(s)`}
|
| 391 |
+
</span>
|
| 392 |
+
</div>
|
| 393 |
+
</div>
|
| 394 |
+
|
| 395 |
+
{/* 2. Main Workspace Layout */}
|
| 396 |
+
<div className="flex-1 flex overflow-hidden">
|
| 397 |
+
|
| 398 |
+
{/* Left Side: Navigation Sidebar & Disk Stats Visualizer */}
|
| 399 |
+
<aside className="w-1/3 border-r-[3px] border-ink-black bg-[#fdf8e1] p-3 overflow-y-auto custom-scrollbar flex flex-col gap-4 shrink-0">
|
| 400 |
+
{/* Folders tree */}
|
| 401 |
+
<div>
|
| 402 |
+
<span className="text-[9px] font-arcade text-slate-500 block uppercase mb-1.5">Folders</span>
|
| 403 |
+
<div className="space-y-1">
|
| 404 |
+
<button
|
| 405 |
+
onClick={() => setCurrentPath(['C:'])}
|
| 406 |
+
className={`w-full text-left p-1.5 flex items-center gap-1.5 font-semibold border-2 border-transparent hover:bg-slate-100 rounded ${currentPath.length === 1 ? 'bg-white border-ink-black' : ''}`}
|
| 407 |
+
>
|
| 408 |
+
<span className="material-symbols-outlined text-[#855400] text-sm" style={{ fontVariationSettings: "'FILL' 1" }}>desktop_windows</span>
|
| 409 |
+
<span>C: (Root)</span>
|
| 410 |
+
</button>
|
| 411 |
+
<div className="pl-4 space-y-1">
|
| 412 |
+
<button
|
| 413 |
+
onClick={() => navigateTo('Database_Codes')}
|
| 414 |
+
className={`w-full text-left p-1.5 flex items-center gap-1.5 font-semibold border-2 border-transparent hover:bg-slate-100 rounded ${activeFolder === 'Database_Codes' ? 'bg-white border-ink-black' : ''}`}
|
| 415 |
+
>
|
| 416 |
+
<span className="material-symbols-outlined text-yellow-600 text-sm" style={{ fontVariationSettings: "'FILL' 1" }}>folder</span>
|
| 417 |
+
<span className="truncate">Database_Codes</span>
|
| 418 |
+
</button>
|
| 419 |
+
<button
|
| 420 |
+
onClick={() => navigateTo('Wallpapers')}
|
| 421 |
+
className={`w-full text-left p-1.5 flex items-center gap-1.5 font-semibold border-2 border-transparent hover:bg-slate-100 rounded ${activeFolder === 'Wallpapers' ? 'bg-white border-ink-black' : ''}`}
|
| 422 |
+
>
|
| 423 |
+
<span className="material-symbols-outlined text-yellow-600 text-sm" style={{ fontVariationSettings: "'FILL' 1" }}>folder</span>
|
| 424 |
+
<span className="truncate">Wallpapers</span>
|
| 425 |
+
</button>
|
| 426 |
+
</div>
|
| 427 |
+
</div>
|
| 428 |
+
</div>
|
| 429 |
+
|
| 430 |
+
{/* Feature 5: Disk Stats Visualizer */}
|
| 431 |
+
<div className="border-t-2 border-dashed border-slate-300 pt-3">
|
| 432 |
+
<span className="text-[9px] font-arcade text-slate-500 block uppercase mb-2">System Statistics</span>
|
| 433 |
+
|
| 434 |
+
<div className="space-y-3 font-sans text-[11px] font-bold text-slate-700">
|
| 435 |
+
{/* Completion Rate ProgressBar */}
|
| 436 |
+
<div className="space-y-1">
|
| 437 |
+
<div className="flex justify-between text-[10px]">
|
| 438 |
+
<span>CODE COMPLETION:</span>
|
| 439 |
+
<span className="font-mono text-emerald-600">{completionPercent}%</span>
|
| 440 |
+
</div>
|
| 441 |
+
<div className="w-full h-4 bg-white border-2 border-ink-black overflow-hidden relative">
|
| 442 |
+
<div
|
| 443 |
+
style={{ width: `${completionPercent}%` }}
|
| 444 |
+
className="h-full bg-emerald-500 border-r-2 border-ink-black transition-all duration-500"
|
| 445 |
+
/>
|
| 446 |
+
</div>
|
| 447 |
+
</div>
|
| 448 |
+
|
| 449 |
+
{/* Box Level Counts visual bar charts */}
|
| 450 |
+
<div className="space-y-1.5 mt-2">
|
| 451 |
+
<span className="text-[9px] uppercase text-slate-500 block">Leitner Distribution:</span>
|
| 452 |
+
{([1, 2, 3, 4, 5] as const).map(boxNum => {
|
| 453 |
+
const count = boxCounts[boxNum];
|
| 454 |
+
const maxCount = Math.max(...Object.values(boxCounts), 1);
|
| 455 |
+
const barWidth = Math.max(8, Math.round((count / maxCount) * 100));
|
| 456 |
+
|
| 457 |
+
return (
|
| 458 |
+
<div key={boxNum} className="flex items-center gap-2">
|
| 459 |
+
<span className="w-9 font-mono text-[10px] text-slate-400">Box {boxNum}:</span>
|
| 460 |
+
<div className="flex-1 h-3 bg-white border border-ink-black overflow-hidden relative">
|
| 461 |
+
<div
|
| 462 |
+
style={{ width: `${barWidth}%` }}
|
| 463 |
+
className="h-full bg-[#0ea5e9] border-r border-ink-black transition-all"
|
| 464 |
+
/>
|
| 465 |
+
</div>
|
| 466 |
+
<span className="w-4 text-right font-mono text-[10px] text-slate-600">{count}</span>
|
| 467 |
+
</div>
|
| 468 |
+
);
|
| 469 |
+
})}
|
| 470 |
+
</div>
|
| 471 |
+
</div>
|
| 472 |
+
</div>
|
| 473 |
+
</aside>
|
| 474 |
+
|
| 475 |
+
{/* Right Side: Folder View Contents & Upload Area */}
|
| 476 |
+
<main
|
| 477 |
+
onDragOver={handleDragOver}
|
| 478 |
+
onDragLeave={handleDragLeave}
|
| 479 |
+
onDrop={handleDrop}
|
| 480 |
+
className={`flex-1 bg-white p-4 overflow-y-auto custom-scrollbar flex flex-col relative transition-all ${
|
| 481 |
+
dragOver ? 'bg-blue-50 border-4 border-dashed border-blue-500 m-2' : ''
|
| 482 |
+
}`}
|
| 483 |
+
>
|
| 484 |
+
{dragOver && (
|
| 485 |
+
<div className="absolute inset-0 flex flex-col items-center justify-center text-blue-800 bg-white/80 pointer-events-none select-none z-[10] gap-2 font-sans font-bold">
|
| 486 |
+
<span className="material-symbols-outlined text-5xl animate-bounce">upload_file</span>
|
| 487 |
+
<div className="text-sm">DROP SOLUTIONS PYTHON SCRIPT (.PY) HERE</div>
|
| 488 |
+
<p className="text-[10px] text-slate-400">Will load and parse file metadata for import</p>
|
| 489 |
+
</div>
|
| 490 |
+
)}
|
| 491 |
+
|
| 492 |
+
{loading ? (
|
| 493 |
+
<div className="flex-1 flex flex-col items-center justify-center font-arcade text-[8px] animate-pulse">
|
| 494 |
+
READING SYSTEM DISK...
|
| 495 |
+
</div>
|
| 496 |
+
) : (
|
| 497 |
+
<>
|
| 498 |
+
{/* Feature 2: Search and Filter Bar Header */}
|
| 499 |
+
{activeFolder === 'Database_Codes' && (
|
| 500 |
+
<div className="mb-4 bg-slate-50 border-[2px] border-ink-black p-2 flex flex-col gap-2 shrink-0 select-none">
|
| 501 |
+
<div className="flex gap-2">
|
| 502 |
+
{/* Search Field */}
|
| 503 |
+
<div className="flex-1 bg-white border border-slate-300 rounded px-2 py-1 flex items-center gap-1">
|
| 504 |
+
<span className="material-symbols-outlined text-slate-400 text-sm">search</span>
|
| 505 |
+
<input
|
| 506 |
+
type="text"
|
| 507 |
+
value={searchQuery}
|
| 508 |
+
onChange={(e) => setSearchQuery(e.target.value)}
|
| 509 |
+
className="w-full bg-transparent outline-none border-none p-0 text-[11px] focus:ring-0 font-sans"
|
| 510 |
+
placeholder="Search codes by name..."
|
| 511 |
+
/>
|
| 512 |
+
</div>
|
| 513 |
+
</div>
|
| 514 |
+
<div className="flex gap-3 text-[10px] font-bold text-slate-600">
|
| 515 |
+
{/* Difficulty Filter */}
|
| 516 |
+
<div className="flex items-center gap-1">
|
| 517 |
+
<span>Diff:</span>
|
| 518 |
+
<select
|
| 519 |
+
value={difficultyFilter}
|
| 520 |
+
onChange={(e) => setDifficultyFilter(e.target.value)}
|
| 521 |
+
className="bg-white border border-slate-300 p-0.5 text-[9px] rounded font-sans cursor-pointer focus:ring-1 outline-none"
|
| 522 |
+
>
|
| 523 |
+
<option value="All">All</option>
|
| 524 |
+
<option value="Easy">Easy</option>
|
| 525 |
+
<option value="Medium">Medium</option>
|
| 526 |
+
<option value="Hard">Hard</option>
|
| 527 |
+
</select>
|
| 528 |
+
</div>
|
| 529 |
+
{/* Pattern Filter */}
|
| 530 |
+
<div className="flex items-center gap-1 flex-1">
|
| 531 |
+
<span>Pattern:</span>
|
| 532 |
+
<select
|
| 533 |
+
value={patternFilter}
|
| 534 |
+
onChange={(e) => setPatternFilter(e.target.value)}
|
| 535 |
+
className="bg-white border border-slate-300 p-0.5 text-[9px] rounded font-sans cursor-pointer max-w-[120px] focus:ring-1 outline-none truncate"
|
| 536 |
+
>
|
| 537 |
+
<option value="All">All</option>
|
| 538 |
+
{uniquePatterns.map(pat => (
|
| 539 |
+
<option key={pat} value={pat}>{pat}</option>
|
| 540 |
+
))}
|
| 541 |
+
</select>
|
| 542 |
+
</div>
|
| 543 |
+
</div>
|
| 544 |
+
</div>
|
| 545 |
+
)}
|
| 546 |
+
|
| 547 |
+
{/* Root Content View */}
|
| 548 |
+
{activeFolder === 'root' && (
|
| 549 |
+
<div className="grid grid-cols-2 gap-4">
|
| 550 |
+
<div
|
| 551 |
+
onDoubleClick={() => navigateTo('Database_Codes')}
|
| 552 |
+
onClick={() => setSelectedItem('codes')}
|
| 553 |
+
className={`flex flex-col items-center justify-center p-3 border-[3px] border-transparent hover:bg-slate-50 cursor-pointer rounded select-none ${selectedItem === 'codes' ? 'bg-yellow-50 border-dashed border-ink-black' : ''}`}
|
| 554 |
+
>
|
| 555 |
+
<span className="material-symbols-outlined text-5xl text-yellow-500" style={{ fontVariationSettings: "'FILL' 1" }}>folder</span>
|
| 556 |
+
<span className="font-bold text-center mt-2 text-xs">Database_Codes</span>
|
| 557 |
+
<span className="text-[9px] text-slate-400 mt-0.5">Synced solution files</span>
|
| 558 |
+
</div>
|
| 559 |
+
<div
|
| 560 |
+
onDoubleClick={() => navigateTo('Wallpapers')}
|
| 561 |
+
onClick={() => setSelectedItem('wallpapers')}
|
| 562 |
+
className={`flex flex-col items-center justify-center p-3 border-[3px] border-transparent hover:bg-slate-50 cursor-pointer rounded select-none ${selectedItem === 'wallpapers' ? 'bg-yellow-50 border-dashed border-ink-black' : ''}`}
|
| 563 |
+
>
|
| 564 |
+
<span className="material-symbols-outlined text-5xl text-yellow-500" style={{ fontVariationSettings: "'FILL' 1" }}>folder</span>
|
| 565 |
+
<span className="font-bold text-center mt-2 text-xs">Wallpapers</span>
|
| 566 |
+
<span className="text-[9px] text-slate-400 mt-0.5">Desktop wallpaper images</span>
|
| 567 |
+
</div>
|
| 568 |
+
</div>
|
| 569 |
+
)}
|
| 570 |
+
|
| 571 |
+
{/* Database Codes Content View */}
|
| 572 |
+
{activeFolder === 'Database_Codes' && (
|
| 573 |
+
filteredProblems.length === 0 ? (
|
| 574 |
+
<div className="flex-1 flex flex-col items-center justify-center text-center text-slate-400 p-6 italic select-none">
|
| 575 |
+
<span className="material-symbols-outlined text-3xl mb-1">drafts</span>
|
| 576 |
+
No matching files found. Drag and drop a .py script to upload/create!
|
| 577 |
+
</div>
|
| 578 |
+
) : (
|
| 579 |
+
<div className="grid grid-cols-3 gap-2">
|
| 580 |
+
{filteredProblems.map((prob) => {
|
| 581 |
+
const cleanName = prob.name.replace(/\s+/g, '_');
|
| 582 |
+
const fileName = `${cleanName}.py`;
|
| 583 |
+
const isSelected = selectedItem === prob.id;
|
| 584 |
+
|
| 585 |
+
return (
|
| 586 |
+
<div
|
| 587 |
+
key={prob.id}
|
| 588 |
+
onClick={() => setSelectedItem(prob.id)}
|
| 589 |
+
onDoubleClick={() => {
|
| 590 |
+
setPreviewFile(prob);
|
| 591 |
+
setEditedCode(prob.reference_code || '');
|
| 592 |
+
setEditedDescription(prob.description || '');
|
| 593 |
+
setModalTab('description');
|
| 594 |
+
setIsEditing(false);
|
| 595 |
+
}}
|
| 596 |
+
className={`flex flex-col items-center p-2 border-[3px] border-transparent hover:bg-slate-50 cursor-pointer rounded select-none text-center ${isSelected ? 'bg-yellow-50 border-dashed border-ink-black' : ''}`}
|
| 597 |
+
>
|
| 598 |
+
<span className="material-symbols-outlined text-3xl text-emerald-600">article</span>
|
| 599 |
+
<span className="font-bold text-[10px] truncate w-full mt-1.5 leading-tight">{fileName}</span>
|
| 600 |
+
<span className="text-[8px] text-slate-400 font-bold uppercase mt-0.5">{prob.difficulty}</span>
|
| 601 |
+
</div>
|
| 602 |
+
);
|
| 603 |
+
})}
|
| 604 |
+
</div>
|
| 605 |
+
)
|
| 606 |
+
)}
|
| 607 |
+
|
| 608 |
+
{/* Wallpapers Content View */}
|
| 609 |
+
{activeFolder === 'Wallpapers' && (
|
| 610 |
+
<div className="grid grid-cols-2 gap-3 select-none">
|
| 611 |
+
{wallpapersList.map((paper) => {
|
| 612 |
+
const isSelected = selectedItem === paper.name;
|
| 613 |
+
const isActive = activeWallpaperUrl === paper.url;
|
| 614 |
+
|
| 615 |
+
return (
|
| 616 |
+
<div
|
| 617 |
+
key={paper.name}
|
| 618 |
+
onClick={() => setSelectedItem(paper.name)}
|
| 619 |
+
className={`border-[3px] p-2 flex flex-col cursor-pointer bg-slate-50 relative hover:bg-slate-100 ${isSelected ? 'border-ink-black bg-yellow-50 shadow-[2px_2px_0_0_#1E293B]' : 'border-slate-300'}`}
|
| 620 |
+
>
|
| 621 |
+
<div className="aspect-video w-full border border-slate-300 overflow-hidden bg-black shrink-0 relative">
|
| 622 |
+
<img src={paper.url} alt={paper.name} className="w-full h-full object-cover select-none" />
|
| 623 |
+
{isActive && (
|
| 624 |
+
<div className="absolute top-1 right-1 bg-green-500 text-white rounded-full w-5 h-5 flex items-center justify-center border border-white" title="Active Wallpaper">
|
| 625 |
+
<span className="material-symbols-outlined text-[12px] font-extrabold">check</span>
|
| 626 |
+
</div>
|
| 627 |
+
)}
|
| 628 |
+
</div>
|
| 629 |
+
<span className="font-bold text-[10px] mt-1.5 truncate">{paper.name}</span>
|
| 630 |
+
<p className="text-[8px] text-slate-400 font-sans leading-tight mt-0.5 line-clamp-2">{paper.desc}</p>
|
| 631 |
+
|
| 632 |
+
{isSelected && (
|
| 633 |
+
<button
|
| 634 |
+
onClick={(e) => {
|
| 635 |
+
e.stopPropagation();
|
| 636 |
+
onSetWallpaper(paper.url);
|
| 637 |
+
localStorage.setItem('desktopWallpaperUrl', paper.url);
|
| 638 |
+
}}
|
| 639 |
+
className="mt-2 w-full py-1 bg-highlight-pink text-white border-2 border-ink-black font-window-title font-bold text-[9px] hover:bg-pink-400 shadow-[1px_1px_0px_rgba(0,0,0,1)] active:translate-y-0.5 active:shadow-none transition-all text-center cursor-pointer"
|
| 640 |
+
>
|
| 641 |
+
APPLY BACKGROUND
|
| 642 |
+
</button>
|
| 643 |
+
)}
|
| 644 |
+
</div>
|
| 645 |
+
);
|
| 646 |
+
})}
|
| 647 |
+
</div>
|
| 648 |
+
)}
|
| 649 |
+
</>
|
| 650 |
+
)}
|
| 651 |
+
</main>
|
| 652 |
+
</div>
|
| 653 |
+
|
| 654 |
+
{/* Feature 3 & 1: Two-Tab File Previewer & Editor Inspector Modal */}
|
| 655 |
+
{previewFile && (
|
| 656 |
+
<div className="absolute inset-0 bg-black/60 z-[999] flex items-center justify-center p-4">
|
| 657 |
+
<div className="w-[90%] h-[90%] bg-paper-white border-[4px] border-ink-black shadow-[6px_6px_0px_0px_#1E293B] flex flex-col overflow-hidden animate-scale-up text-ink-black">
|
| 658 |
+
|
| 659 |
+
{/* Modal Title bar */}
|
| 660 |
+
<div className="bg-emerald-600 text-white p-2 border-b-[3px] border-ink-black flex justify-between items-center shrink-0 select-none">
|
| 661 |
+
<div className="flex items-center gap-1.5">
|
| 662 |
+
<span className="material-symbols-outlined text-sm">article</span>
|
| 663 |
+
<span className="font-window-title font-bold text-xs">{previewFile.name.replace(/\s+/g, '_')}.py</span>
|
| 664 |
+
</div>
|
| 665 |
+
<button
|
| 666 |
+
onClick={() => setPreviewFile(null)}
|
| 667 |
+
className="bg-white text-ink-black border-[2px] border-ink-black w-5 h-5 flex items-center justify-center font-bold hover:bg-red-500 hover:text-white cursor-pointer"
|
| 668 |
+
>
|
| 669 |
+
X
|
| 670 |
+
</button>
|
| 671 |
+
</div>
|
| 672 |
+
|
| 673 |
+
{/* Triple Tab selectors */}
|
| 674 |
+
<div className="bg-slate-200 border-b-2 border-ink-black flex shrink-0 select-none">
|
| 675 |
+
<button
|
| 676 |
+
onClick={() => setModalTab('description')}
|
| 677 |
+
className={`px-4 py-2 border-r-2 border-ink-black font-bold text-[11px] cursor-pointer ${modalTab === 'description' ? 'bg-white translate-y-[2px]' : 'bg-slate-300 hover:bg-slate-100'}`}
|
| 678 |
+
>
|
| 679 |
+
PROBLEM DESCRIPTION
|
| 680 |
+
</button>
|
| 681 |
+
<button
|
| 682 |
+
onClick={() => setModalTab('code')}
|
| 683 |
+
className={`px-4 py-2 border-r-2 border-ink-black font-bold text-[11px] cursor-pointer ${modalTab === 'code' ? 'bg-white translate-y-[2px]' : 'bg-slate-300 hover:bg-slate-100'}`}
|
| 684 |
+
>
|
| 685 |
+
CODE VIEW & EDIT
|
| 686 |
+
</button>
|
| 687 |
+
<button
|
| 688 |
+
onClick={() => setModalTab('properties')}
|
| 689 |
+
className={`px-4 py-2 border-r-2 border-ink-black font-bold text-[11px] cursor-pointer ${modalTab === 'properties' ? 'bg-white translate-y-[2px]' : 'bg-slate-300 hover:bg-slate-100'}`}
|
| 690 |
+
>
|
| 691 |
+
LEITNER PROPERTIES
|
| 692 |
+
</button>
|
| 693 |
+
</div>
|
| 694 |
+
|
| 695 |
+
{/* TAB CONTENT: Problem Description */}
|
| 696 |
+
{modalTab === 'description' && (
|
| 697 |
+
<div className="flex-1 flex flex-col overflow-hidden bg-slate-900 relative">
|
| 698 |
+
{isEditing ? (
|
| 699 |
+
<textarea
|
| 700 |
+
value={editedDescription}
|
| 701 |
+
onChange={(e) => setEditedDescription(e.target.value)}
|
| 702 |
+
className="flex-1 w-full bg-slate-950 text-[#4ADE80] font-mono p-4 outline-none border-none text-xs leading-relaxed resize-none custom-scrollbar"
|
| 703 |
+
placeholder="Enter problem description / question body here..."
|
| 704 |
+
spellCheck="false"
|
| 705 |
+
/>
|
| 706 |
+
) : (
|
| 707 |
+
<div className="flex-1 p-4 bg-slate-900 text-[#4ADE80] font-sans text-xs leading-relaxed overflow-y-auto custom-scrollbar select-text">
|
| 708 |
+
<div className="whitespace-pre-wrap font-semibold">{previewFile.description || 'No description provided for this problem.'}</div>
|
| 709 |
+
</div>
|
| 710 |
+
)}
|
| 711 |
+
|
| 712 |
+
{/* Edit indicators */}
|
| 713 |
+
{isEditing && (
|
| 714 |
+
<div className="absolute top-2 right-2 bg-yellow-400 text-ink-black border border-ink-black px-2 py-0.5 text-[9px] font-bold select-none animate-pulse">
|
| 715 |
+
EDIT MODE ACTIVE
|
| 716 |
+
</div>
|
| 717 |
+
)}
|
| 718 |
+
</div>
|
| 719 |
+
)}
|
| 720 |
+
|
| 721 |
+
{/* TAB CONTENT: Code preview & editing */}
|
| 722 |
+
{modalTab === 'code' && (
|
| 723 |
+
<div className="flex-1 flex flex-col overflow-hidden bg-slate-900 relative">
|
| 724 |
+
{isEditing ? (
|
| 725 |
+
<textarea
|
| 726 |
+
value={editedCode}
|
| 727 |
+
onChange={(e) => setEditedCode(e.target.value)}
|
| 728 |
+
className="flex-1 w-full bg-slate-950 text-[#4ADE80] font-mono p-4 outline-none border-none text-xs leading-relaxed resize-none custom-scrollbar"
|
| 729 |
+
spellCheck="false"
|
| 730 |
+
/>
|
| 731 |
+
) : (
|
| 732 |
+
<textarea
|
| 733 |
+
readOnly
|
| 734 |
+
value={previewFile.reference_code || '# Write your solution here.'}
|
| 735 |
+
className="flex-1 w-full bg-slate-900 text-[#4ADE80] font-mono p-4 outline-none border-none text-xs leading-relaxed resize-none custom-scrollbar"
|
| 736 |
+
spellCheck="false"
|
| 737 |
+
/>
|
| 738 |
+
)}
|
| 739 |
+
|
| 740 |
+
{/* Edit indicators */}
|
| 741 |
+
{isEditing && (
|
| 742 |
+
<div className="absolute top-2 right-2 bg-yellow-400 text-ink-black border border-ink-black px-2 py-0.5 text-[9px] font-bold select-none animate-pulse">
|
| 743 |
+
EDIT MODE ACTIVE
|
| 744 |
+
</div>
|
| 745 |
+
)}
|
| 746 |
+
</div>
|
| 747 |
+
)}
|
| 748 |
+
|
| 749 |
+
{/* TAB CONTENT: Leitner properties */}
|
| 750 |
+
{modalTab === 'properties' && (
|
| 751 |
+
<div className="flex-1 p-6 overflow-y-auto custom-scrollbar bg-slate-50 font-sans leading-relaxed select-text flex flex-col gap-4">
|
| 752 |
+
<h3 className="font-arcade text-[10px] border-b border-slate-300 pb-1 text-slate-500">Card Diagnostics</h3>
|
| 753 |
+
|
| 754 |
+
<div className="grid grid-cols-2 gap-4">
|
| 755 |
+
<div className="border-[3px] border-ink-black bg-white p-3 space-y-2">
|
| 756 |
+
<span className="text-[9px] font-arcade text-[#0ea5e9] block">Scheduling Status</span>
|
| 757 |
+
<div>Leitner Combo Box: <span className="font-mono font-bold text-lg text-sky-600">{previewFile.box_level || 'N/A (Box 1)'}</span></div>
|
| 758 |
+
<div>Next Review Date: <span className="font-mono text-xs font-semibold">{previewFile.next_review ? new Date(previewFile.next_review).toLocaleString() : 'N/A (Due)'}</span></div>
|
| 759 |
+
<div>Last Reviewed: <span className="font-mono text-xs font-semibold">{previewFile.last_reviewed ? new Date(previewFile.last_reviewed).toLocaleDateString() : 'Never'}</span></div>
|
| 760 |
+
</div>
|
| 761 |
+
|
| 762 |
+
<div className="border-[3px] border-ink-black bg-white p-3 space-y-2">
|
| 763 |
+
<span className="text-[9px] font-arcade text-emerald-600 block">Performance History</span>
|
| 764 |
+
<div>Total Attempts: <span className="font-mono font-bold text-sm">{previewFile.total_attempts}</span></div>
|
| 765 |
+
<div>Times Correct: <span className="font-mono font-bold text-sm text-green-600">{previewFile.times_correct}</span></div>
|
| 766 |
+
<div>Success Ratio: <span className="font-mono font-bold text-sm text-purple-700">
|
| 767 |
+
{previewFile.total_attempts > 0 ? `${Math.round((previewFile.times_correct / previewFile.total_attempts) * 100)}%` : '0%'}
|
| 768 |
+
</span></div>
|
| 769 |
+
</div>
|
| 770 |
+
</div>
|
| 771 |
+
|
| 772 |
+
<div className="border-[3px] border-ink-black bg-white p-3 space-y-1.5 text-xs font-semibold">
|
| 773 |
+
<span className="text-[9px] font-arcade text-slate-400 block">Properties</span>
|
| 774 |
+
<div>File ID: <span className="font-mono text-[10px] text-slate-500">{previewFile.id}</span></div>
|
| 775 |
+
<div>Logical Pattern: <span className="text-blue-600">{previewFile.pattern}</span></div>
|
| 776 |
+
<div>Difficulty Tag: <span className="uppercase">{previewFile.difficulty}</span></div>
|
| 777 |
+
</div>
|
| 778 |
+
</div>
|
| 779 |
+
)}
|
| 780 |
+
|
| 781 |
+
{/* Modal Footer controls */}
|
| 782 |
+
<div className="p-3 bg-slate-100 border-t-[3px] border-ink-black flex justify-between items-center shrink-0 select-none">
|
| 783 |
+
<div>
|
| 784 |
+
{saveSuccess && (
|
| 785 |
+
<span className="text-green-600 font-bold text-xs animate-pulse">✓ Solution saved!</span>
|
| 786 |
+
)}
|
| 787 |
+
</div>
|
| 788 |
+
<div className="flex gap-2">
|
| 789 |
+
{(modalTab === 'code' || modalTab === 'description') && (
|
| 790 |
+
isEditing ? (
|
| 791 |
+
<>
|
| 792 |
+
<button
|
| 793 |
+
onClick={() => {
|
| 794 |
+
setEditedCode(previewFile.reference_code || '');
|
| 795 |
+
setEditedDescription(previewFile.description || '');
|
| 796 |
+
setIsEditing(false);
|
| 797 |
+
}}
|
| 798 |
+
className="px-3 py-1.5 bg-white hover:bg-slate-50 text-ink-black border-2 border-ink-black font-bold text-xs cursor-pointer"
|
| 799 |
+
>
|
| 800 |
+
CANCEL
|
| 801 |
+
</button>
|
| 802 |
+
<button
|
| 803 |
+
disabled={saving}
|
| 804 |
+
onClick={handleSaveSolution}
|
| 805 |
+
className="px-3 py-1.5 bg-[#4ADE80] hover:bg-emerald-400 text-ink-black border-2 border-ink-black font-bold text-xs cursor-pointer shadow-[2px_2px_0px_rgba(0,0,0,1)] active:translate-y-0.5 active:shadow-none"
|
| 806 |
+
>
|
| 807 |
+
{saving ? 'SAVING...' : 'SAVE CHANGES'}
|
| 808 |
+
</button>
|
| 809 |
+
</>
|
| 810 |
+
) : (
|
| 811 |
+
<button
|
| 812 |
+
onClick={() => setIsEditing(true)}
|
| 813 |
+
className="px-3 py-1.5 bg-yellow-400 hover:bg-yellow-300 text-ink-black border-2 border-ink-black font-bold text-xs cursor-pointer shadow-[2px_2px_0px_rgba(0,0,0,1)] active:translate-y-0.5 active:shadow-none flex items-center gap-1"
|
| 814 |
+
>
|
| 815 |
+
<span className="material-symbols-outlined text-sm">edit</span>
|
| 816 |
+
EDIT FILE
|
| 817 |
+
</button>
|
| 818 |
+
)
|
| 819 |
+
)}
|
| 820 |
+
|
| 821 |
+
<button
|
| 822 |
+
onClick={() => handleCopyCode(isEditing ? editedCode : previewFile.reference_code)}
|
| 823 |
+
className="px-3 py-1.5 bg-[#ffcc00] hover:bg-yellow-300 text-ink-black border-2 border-ink-black font-bold text-xs shadow-[2px_2px_0px_rgba(0,0,0,1)] active:translate-y-0.5 active:shadow-none cursor-pointer flex items-center gap-1"
|
| 824 |
+
>
|
| 825 |
+
<span className="material-symbols-outlined text-sm">content_copy</span>
|
| 826 |
+
{copied ? 'COPIED!' : 'COPY CODE'}
|
| 827 |
+
</button>
|
| 828 |
+
<button
|
| 829 |
+
onClick={() => setPreviewFile(null)}
|
| 830 |
+
className="px-3 py-1.5 bg-white hover:bg-slate-50 text-ink-black border-2 border-ink-black font-bold text-xs shadow-[2px_2px_0px_rgba(0,0,0,1)] active:translate-y-0.5 active:shadow-none cursor-pointer"
|
| 831 |
+
>
|
| 832 |
+
CLOSE
|
| 833 |
+
</button>
|
| 834 |
+
</div>
|
| 835 |
+
</div>
|
| 836 |
+
</div>
|
| 837 |
+
</div>
|
| 838 |
+
)}
|
| 839 |
+
|
| 840 |
+
{/* Feature 1: New File Creator Modal Dialog */}
|
| 841 |
+
{isCreateModalOpen && (
|
| 842 |
+
<div className="absolute inset-0 bg-black/60 z-[999] flex items-center justify-center p-4 select-none">
|
| 843 |
+
<div className="w-[80%] max-w-[500px] bg-paper-white border-[4px] border-ink-black shadow-[6px_6px_0px_0px_#1E293B] flex flex-col overflow-hidden animate-scale-up text-ink-black font-sans">
|
| 844 |
+
|
| 845 |
+
{/* Modal Header */}
|
| 846 |
+
<div className="bg-primary-purple text-white p-2.5 border-b-[3px] border-ink-black flex justify-between items-center font-bold">
|
| 847 |
+
<span className="font-window-title text-xs">CREATE NEW SOLUTION FILE</span>
|
| 848 |
+
<button
|
| 849 |
+
onClick={() => setIsCreateModalOpen(false)}
|
| 850 |
+
className="bg-white text-ink-black border-[2px] border-ink-black w-5 h-5 flex items-center justify-center font-bold hover:bg-red-500 hover:text-white cursor-pointer"
|
| 851 |
+
>
|
| 852 |
+
X
|
| 853 |
+
</button>
|
| 854 |
+
</div>
|
| 855 |
+
|
| 856 |
+
{/* Modal Body form */}
|
| 857 |
+
<form onSubmit={handleCreateProblem} className="p-4 space-y-3 flex-1 overflow-y-auto custom-scrollbar">
|
| 858 |
+
{createError && (
|
| 859 |
+
<div className="p-2 border-2 border-red-500 bg-red-50 text-red-800 text-[10px] font-mono leading-relaxed">
|
| 860 |
+
{createError}
|
| 861 |
+
</div>
|
| 862 |
+
)}
|
| 863 |
+
|
| 864 |
+
{/* Problem Name */}
|
| 865 |
+
<div className="flex flex-col gap-1">
|
| 866 |
+
<label className="text-[10px] font-bold text-slate-500 uppercase">Problem Name:</label>
|
| 867 |
+
<input
|
| 868 |
+
type="text"
|
| 869 |
+
required
|
| 870 |
+
value={newFileName}
|
| 871 |
+
onChange={(e) => setNewFileName(e.target.value)}
|
| 872 |
+
placeholder="e.g. Two Sum"
|
| 873 |
+
className="bg-white border-2 border-ink-black p-1.5 text-xs outline-none focus:ring-1 focus:ring-primary-purple font-semibold"
|
| 874 |
+
/>
|
| 875 |
+
</div>
|
| 876 |
+
|
| 877 |
+
{/* Logical Pattern Category */}
|
| 878 |
+
<div className="flex flex-col gap-1">
|
| 879 |
+
<label className="text-[10px] font-bold text-slate-500 uppercase">Pattern Category:</label>
|
| 880 |
+
<input
|
| 881 |
+
type="text"
|
| 882 |
+
value={newFilePattern}
|
| 883 |
+
onChange={(e) => setNewFilePattern(e.target.value)}
|
| 884 |
+
placeholder="e.g. Sliding Window"
|
| 885 |
+
className="bg-white border-2 border-ink-black p-1.5 text-xs outline-none focus:ring-1 focus:ring-primary-purple font-semibold"
|
| 886 |
+
/>
|
| 887 |
+
</div>
|
| 888 |
+
|
| 889 |
+
{/* Difficulty Selection */}
|
| 890 |
+
<div className="flex flex-col gap-1">
|
| 891 |
+
<label className="text-[10px] font-bold text-slate-500 uppercase">Difficulty:</label>
|
| 892 |
+
<select
|
| 893 |
+
value={newFileDifficulty}
|
| 894 |
+
onChange={(e) => setNewFileDifficulty(e.target.value)}
|
| 895 |
+
className="bg-white border-2 border-ink-black p-1.5 text-xs outline-none focus:ring-1 focus:ring-primary-purple font-bold cursor-pointer"
|
| 896 |
+
>
|
| 897 |
+
<option value="Easy">Easy</option>
|
| 898 |
+
<option value="Medium">Medium</option>
|
| 899 |
+
<option value="Hard">Hard</option>
|
| 900 |
+
</select>
|
| 901 |
+
</div>
|
| 902 |
+
|
| 903 |
+
{/* Problem Description */}
|
| 904 |
+
<div className="flex flex-col gap-1">
|
| 905 |
+
<label className="text-[10px] font-bold text-slate-500 uppercase">Problem Description:</label>
|
| 906 |
+
<textarea
|
| 907 |
+
value={newFileDescription}
|
| 908 |
+
onChange={(e) => setNewFileDescription(e.target.value)}
|
| 909 |
+
placeholder="e.g. Given an array of integers, return indices of the two numbers such that they add up to a specific target."
|
| 910 |
+
className="bg-white border-2 border-ink-black p-1.5 text-xs outline-none focus:ring-1 focus:ring-primary-purple font-semibold h-[70px] resize-none"
|
| 911 |
+
/>
|
| 912 |
+
</div>
|
| 913 |
+
|
| 914 |
+
{/* Solution Code */}
|
| 915 |
+
<div className="flex flex-col gap-1">
|
| 916 |
+
<label className="text-[10px] font-bold text-slate-500 uppercase">Reference Python Code:</label>
|
| 917 |
+
<textarea
|
| 918 |
+
value={newFileCode}
|
| 919 |
+
onChange={(e) => setNewFileCode(e.target.value)}
|
| 920 |
+
placeholder="def solve():\n pass"
|
| 921 |
+
className="bg-slate-900 text-[#4ADE80] font-mono p-2 border-2 border-ink-black rounded outline-none h-[120px] text-xs leading-relaxed resize-none"
|
| 922 |
+
/>
|
| 923 |
+
</div>
|
| 924 |
+
|
| 925 |
+
{/* Action Buttons */}
|
| 926 |
+
<div className="pt-3 border-t border-slate-200 flex justify-end gap-3 font-semibold">
|
| 927 |
+
<button
|
| 928 |
+
type="button"
|
| 929 |
+
onClick={() => setIsCreateModalOpen(false)}
|
| 930 |
+
className="px-3 py-1.5 bg-white hover:bg-slate-50 border-2 border-ink-black text-xs cursor-pointer"
|
| 931 |
+
>
|
| 932 |
+
CANCEL
|
| 933 |
+
</button>
|
| 934 |
+
<button
|
| 935 |
+
type="submit"
|
| 936 |
+
disabled={creating}
|
| 937 |
+
className="px-3 py-1.5 bg-primary-purple text-white border-2 border-ink-black text-xs cursor-pointer shadow-[2px_2px_0px_rgba(0,0,0,1)] active:translate-y-0.5 active:shadow-none"
|
| 938 |
+
>
|
| 939 |
+
{creating ? 'CREATING...' : 'ADD SOLUTION'}
|
| 940 |
+
</button>
|
| 941 |
+
</div>
|
| 942 |
+
</form>
|
| 943 |
+
</div>
|
| 944 |
+
</div>
|
| 945 |
+
)}
|
| 946 |
+
</div>
|
| 947 |
+
);
|
| 948 |
+
}
|
algospaced-ui/src/components/JourneyPath.tsx
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from 'react';
|
| 2 |
+
import { supabase } from '../supabaseClient';
|
| 3 |
+
import { ExternalLink, Search, CheckCircle, XCircle, ArrowLeft, RefreshCw, Star, Info } from 'lucide-react';
|
| 4 |
+
|
| 5 |
+
const JOURNEY_SEQUENCE = [
|
| 6 |
+
217, 242, 1, 49, 347, 271, 238, 36, 128, 125, 167, 15, 11, 42, 121, 3, 424, 567, 76, 239, 20,
|
| 7 |
+
155, 150, 739, 853, 84, 704, 74, 875, 153, 33, 981, 4, 206, 21, 141, 143, 19, 138, 2, 287,
|
| 8 |
+
146, 23, 25, 226, 104, 543, 110, 100, 572, 235, 102, 199, 1448, 98, 230, 105, 124, 297, 703,
|
| 9 |
+
1046, 973, 215, 621, 355, 295, 78, 39, 40, 46, 90, 22, 79, 131, 17, 51, 208, 211, 212, 200,
|
| 10 |
+
695, 133, 286, 994, 417, 130, 207, 210, 261, 323, 684, 127, 743, 332, 1584, 778, 269, 787,
|
| 11 |
+
70, 746, 198, 213, 5, 647, 91, 322, 152, 139, 300, 416, 62, 1143, 309, 518, 494, 97, 329,
|
| 12 |
+
115, 72, 312, 10, 53, 55, 45, 134, 846, 1899, 763, 678, 57, 56, 435, 252, 253, 1851, 48,
|
| 13 |
+
54, 73, 202, 66, 50, 43, 2013, 136, 191, 338, 190, 268, 371, 7
|
| 14 |
+
];
|
| 15 |
+
|
| 16 |
+
interface JourneyPathProps {
|
| 17 |
+
onSelectProblemForReview: (problemId: string) => void;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export default function JourneyPath({ onSelectProblemForReview }: JourneyPathProps) {
|
| 21 |
+
const [completions, setCompletions] = useState<Set<number>>(new Set());
|
| 22 |
+
const [titles, setTitles] = useState<Record<number, string>>({});
|
| 23 |
+
const [loading, setLoading] = useState(true);
|
| 24 |
+
const [selectedNum, setSelectedNum] = useState<number | null>(null);
|
| 25 |
+
const [details, setDetails] = useState<any[]>([]);
|
| 26 |
+
const [modalLoading, setModalLoading] = useState(false);
|
| 27 |
+
const [toggleLoading, setToggleLoading] = useState(false);
|
| 28 |
+
|
| 29 |
+
const fetchData = async () => {
|
| 30 |
+
try {
|
| 31 |
+
const { data: { session } } = await supabase.auth.getSession();
|
| 32 |
+
if (!session) return;
|
| 33 |
+
|
| 34 |
+
const headers = { 'Authorization': `Bearer ${session.access_token}` };
|
| 35 |
+
|
| 36 |
+
const [progRes, titlesRes] = await Promise.all([
|
| 37 |
+
fetch(`${import.meta.env.VITE_API_BASE_URL}/api/journey-progress`, { headers }),
|
| 38 |
+
fetch(`${import.meta.env.VITE_API_BASE_URL}/api/journey-progress/titles`, { headers })
|
| 39 |
+
]);
|
| 40 |
+
|
| 41 |
+
if (progRes.ok && titlesRes.ok) {
|
| 42 |
+
const progData = await progRes.json();
|
| 43 |
+
const titlesData = await titlesRes.json();
|
| 44 |
+
setCompletions(new Set(progData));
|
| 45 |
+
setTitles(titlesData);
|
| 46 |
+
}
|
| 47 |
+
} catch (err) {
|
| 48 |
+
console.error('Error fetching journey data:', err);
|
| 49 |
+
} finally {
|
| 50 |
+
setLoading(false);
|
| 51 |
+
}
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
useEffect(() => {
|
| 55 |
+
fetchData();
|
| 56 |
+
}, []);
|
| 57 |
+
|
| 58 |
+
const handleNodeClick = async (num: number) => {
|
| 59 |
+
setSelectedNum(num);
|
| 60 |
+
setDetails([]);
|
| 61 |
+
setModalLoading(true);
|
| 62 |
+
try {
|
| 63 |
+
const { data: { session } } = await supabase.auth.getSession();
|
| 64 |
+
if (!session) return;
|
| 65 |
+
|
| 66 |
+
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/reviews/problems/${num}`, {
|
| 67 |
+
headers: { 'Authorization': `Bearer ${session.access_token}` }
|
| 68 |
+
});
|
| 69 |
+
if (res.ok) {
|
| 70 |
+
const data = await res.json();
|
| 71 |
+
setDetails(data);
|
| 72 |
+
}
|
| 73 |
+
} catch (err) {
|
| 74 |
+
console.error('Error fetching problem details:', err);
|
| 75 |
+
} finally {
|
| 76 |
+
setModalLoading(false);
|
| 77 |
+
}
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
+
const handleToggleCompletion = async (num: number) => {
|
| 81 |
+
setToggleLoading(true);
|
| 82 |
+
try {
|
| 83 |
+
const { data: { session } } = await supabase.auth.getSession();
|
| 84 |
+
if (!session) return;
|
| 85 |
+
|
| 86 |
+
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/journey-progress/toggle`, {
|
| 87 |
+
method: 'POST',
|
| 88 |
+
headers: {
|
| 89 |
+
'Content-Type': 'application/json',
|
| 90 |
+
'Authorization': `Bearer ${session.access_token}`
|
| 91 |
+
},
|
| 92 |
+
body: JSON.stringify({ problem_number: num })
|
| 93 |
+
});
|
| 94 |
+
|
| 95 |
+
if (res.ok) {
|
| 96 |
+
const updated = new Set(completions);
|
| 97 |
+
if (updated.has(num)) {
|
| 98 |
+
updated.delete(num);
|
| 99 |
+
} else {
|
| 100 |
+
updated.add(num);
|
| 101 |
+
}
|
| 102 |
+
setCompletions(updated);
|
| 103 |
+
}
|
| 104 |
+
} catch (err) {
|
| 105 |
+
console.error('Error toggling progress:', err);
|
| 106 |
+
} finally {
|
| 107 |
+
setToggleLoading(false);
|
| 108 |
+
}
|
| 109 |
+
};
|
| 110 |
+
|
| 111 |
+
// Compute stats
|
| 112 |
+
const total = JOURNEY_SEQUENCE.length;
|
| 113 |
+
const completedCount = JOURNEY_SEQUENCE.filter(num => completions.has(num)).length;
|
| 114 |
+
const percentage = total > 0 ? Math.round((completedCount / total) * 100) : 0;
|
| 115 |
+
|
| 116 |
+
if (loading) {
|
| 117 |
+
return (
|
| 118 |
+
<div className="w-full h-full flex items-center justify-center bg-[#fdf8e1] text-ink-black">
|
| 119 |
+
<div className="flex flex-col items-center gap-3">
|
| 120 |
+
<RefreshCw className="w-8 h-8 text-primary animate-spin" />
|
| 121 |
+
<span className="font-arcade text-[10px] tracking-wide">COMPILING MAP...</span>
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
);
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
// Determine active node: first node in sequence that is not completed
|
| 128 |
+
const activeNode = JOURNEY_SEQUENCE.find(num => !completions.has(num)) || JOURNEY_SEQUENCE[0];
|
| 129 |
+
|
| 130 |
+
return (
|
| 131 |
+
<div className="w-full h-full bg-[#fdf8e1] p-6 overflow-y-auto custom-scrollbar flex flex-col relative">
|
| 132 |
+
<div className="flex flex-col gap-4">
|
| 133 |
+
{/* Header progress info */}
|
| 134 |
+
<div className="flex flex-col md:flex-row items-center justify-between p-4 bg-white border-[3px] border-ink-black shadow-[4px_4px_0px_0px_rgba(30,41,59,1)] gap-4 select-none">
|
| 135 |
+
<div>
|
| 136 |
+
<h3 className="font-headline-md font-bold text-ink-black text-sm m-0">MAP PROGRESS STATUS</h3>
|
| 137 |
+
<span className="text-[10px] font-arcade text-trash-gray font-bold uppercase mt-1 block">Leitner path completion metrics</span>
|
| 138 |
+
</div>
|
| 139 |
+
<div className="flex items-center gap-4">
|
| 140 |
+
<span className="font-arcade text-[10px] font-bold text-ink-black">{percentage}% DONE</span>
|
| 141 |
+
<div className="w-48 h-6 bg-slate-100 border-[3px] border-ink-black relative overflow-hidden">
|
| 142 |
+
<div
|
| 143 |
+
className="absolute inset-y-0 left-0 bg-[#0ea5e9] border-r-[3px] border-ink-black transition-all duration-500"
|
| 144 |
+
style={{ width: `${percentage}%` }}
|
| 145 |
+
/>
|
| 146 |
+
</div>
|
| 147 |
+
</div>
|
| 148 |
+
</div>
|
| 149 |
+
|
| 150 |
+
{/* Milestone path visualization */}
|
| 151 |
+
<div className="bg-white border-[3px] border-ink-black shadow-[4px_4px_0px_0px_rgba(30,41,59,1)] relative p-8 mt-4 overflow-hidden min-h-[700px] flex flex-col items-center">
|
| 152 |
+
{/* Timeline Center Sine Path via SVG */}
|
| 153 |
+
<svg className="absolute inset-y-0 w-full h-full pointer-events-none" preserveAspectRatio="none" viewBox="0 0 100 1000">
|
| 154 |
+
<path
|
| 155 |
+
d="M 50,0 Q 70,50 30,100 T 50,200 T 70,300 T 30,400 T 50,500 T 70,600 T 30,700 T 50,800 T 70,900 T 50,1000"
|
| 156 |
+
fill="none"
|
| 157 |
+
opacity="0.3"
|
| 158 |
+
stroke="#1E293B"
|
| 159 |
+
strokeDasharray="5,15"
|
| 160 |
+
strokeLinecap="round"
|
| 161 |
+
strokeWidth="6"
|
| 162 |
+
/>
|
| 163 |
+
</svg>
|
| 164 |
+
|
| 165 |
+
{/* Nodes Container */}
|
| 166 |
+
<div className="relative z-10 flex flex-col items-center gap-10 w-full max-w-sm">
|
| 167 |
+
{JOURNEY_SEQUENCE.map((num, index) => {
|
| 168 |
+
const isCompleted = completions.has(num);
|
| 169 |
+
const isSynced = num in titles;
|
| 170 |
+
const title = titles[num] || 'Unsynced Note';
|
| 171 |
+
const isActive = num === activeNode;
|
| 172 |
+
|
| 173 |
+
// Sine wave displacement
|
| 174 |
+
const xOffset = Math.sin(index * 0.8) * 75;
|
| 175 |
+
|
| 176 |
+
return (
|
| 177 |
+
<div
|
| 178 |
+
key={num}
|
| 179 |
+
className="relative group flex items-center justify-center"
|
| 180 |
+
style={{ transform: `translateX(${xOffset}px)` }}
|
| 181 |
+
>
|
| 182 |
+
{/* Tooltip */}
|
| 183 |
+
<div className="absolute bottom-[115%] left-1/2 -translate-x-1/2 bg-paper-white text-ink-black border-[3px] border-ink-black text-[9px] font-arcade px-3 py-2 rounded-xl whitespace-nowrap shadow-[4px_4px_0px_0px_#1E293B] opacity-0 group-hover:opacity-100 transition-all duration-200 scale-90 group-hover:scale-100 pointer-events-none z-30">
|
| 184 |
+
<span className="font-bold text-primary mr-1">#{num}</span>
|
| 185 |
+
<span>{title.toUpperCase()}</span>
|
| 186 |
+
{!isSynced && <span className="ml-2 text-red-500 font-bold">(LOCKED)</span>}
|
| 187 |
+
</div>
|
| 188 |
+
|
| 189 |
+
{/* Circular / Rounded Square Node */}
|
| 190 |
+
<button
|
| 191 |
+
onClick={() => handleNodeClick(num)}
|
| 192 |
+
className={`w-14 h-14 border-[3px] border-ink-black flex items-center justify-center font-arcade font-bold text-xs cursor-pointer transition-all duration-200 hover:scale-110 active:scale-95 ${
|
| 193 |
+
isCompleted
|
| 194 |
+
? 'bg-grass-green text-ink-black shadow-[3px_3px_0px_0px_#1E293B] rounded-lg'
|
| 195 |
+
: isActive
|
| 196 |
+
? 'bg-[#ffe24c] text-ink-black shadow-[4px_4px_0px_0px_#F472B6] rounded-xl animate-bounce border-dashed'
|
| 197 |
+
: isSynced
|
| 198 |
+
? 'bg-[#c0e8ff] text-ink-black shadow-[3px_3px_0px_0px_#1E293B] rounded-lg'
|
| 199 |
+
: 'bg-slate-200 text-trash-gray opacity-60 shadow-[1px_1px_0px_0px_#1E293B] rounded-lg'
|
| 200 |
+
}`}
|
| 201 |
+
>
|
| 202 |
+
{isCompleted ? '✓' : isActive ? '▶' : num}
|
| 203 |
+
</button>
|
| 204 |
+
</div>
|
| 205 |
+
);
|
| 206 |
+
})}
|
| 207 |
+
</div>
|
| 208 |
+
</div>
|
| 209 |
+
</div>
|
| 210 |
+
|
| 211 |
+
{/* Modal Dialog (Styled as a Retro OS System Dialog) */}
|
| 212 |
+
{selectedNum !== null && (
|
| 213 |
+
<div className="fixed inset-0 bg-black/50 backdrop-blur-xs flex items-center justify-center z-[100] p-4 animate-fade-in">
|
| 214 |
+
<div className="w-full max-w-md bg-paper-white border-[4px] border-ink-black p-0 shadow-[8px_8px_0px_0px_rgba(30,41,59,1)] relative select-none">
|
| 215 |
+
{/* Title Bar */}
|
| 216 |
+
<div className="bg-[#0ea5e9] border-b-[4px] border-ink-black px-4 py-2 flex justify-between items-center text-white font-bold text-sm">
|
| 217 |
+
<div className="flex items-center gap-2">
|
| 218 |
+
<span className="material-symbols-outlined text-sm font-bold">info</span>
|
| 219 |
+
<span>Problem_Detail.sys</span>
|
| 220 |
+
</div>
|
| 221 |
+
<button
|
| 222 |
+
onClick={() => setSelectedNum(null)}
|
| 223 |
+
className="w-6 h-6 bg-highlight-pink text-white border-2 border-ink-black flex items-center justify-center hover:bg-rose-600 active:translate-y-0.5 shadow-[1px_1px_0px_0px_rgba(30,41,59,1)] active:shadow-none transition-all cursor-pointer font-bold"
|
| 224 |
+
>
|
| 225 |
+
×
|
| 226 |
+
</button>
|
| 227 |
+
</div>
|
| 228 |
+
|
| 229 |
+
{/* Dialog Content */}
|
| 230 |
+
<div className="p-6 bg-[#fdf8e1] space-y-4">
|
| 231 |
+
<div className="pb-3 border-b-2 border-dashed border-ink-black">
|
| 232 |
+
<span className="text-[8px] font-arcade font-bold text-trash-gray uppercase block">Milestone Index</span>
|
| 233 |
+
<h3 className="text-xl font-bold font-headline-md text-ink-black mt-1">LeetCode Problem #{selectedNum}</h3>
|
| 234 |
+
</div>
|
| 235 |
+
|
| 236 |
+
{modalLoading ? (
|
| 237 |
+
<div className="py-8 flex flex-col items-center justify-center gap-3">
|
| 238 |
+
<RefreshCw className="w-6 h-6 text-primary animate-spin" />
|
| 239 |
+
<span className="text-[8px] font-arcade font-bold text-trash-gray uppercase">FETCHING DATA...</span>
|
| 240 |
+
</div>
|
| 241 |
+
) : details.length > 0 ? (
|
| 242 |
+
<div className="space-y-4">
|
| 243 |
+
{details.map((prob) => (
|
| 244 |
+
<div key={prob.id} className="p-4 bg-white border-[3px] border-ink-black shadow-[3px_3px_0px_0px_rgba(30,41,59,1)] space-y-3">
|
| 245 |
+
<div className="flex justify-between items-start gap-2">
|
| 246 |
+
<div>
|
| 247 |
+
<h4 className="font-bold text-ink-black text-sm font-headline-md leading-tight m-0">{prob.name}</h4>
|
| 248 |
+
<span className="text-[8px] font-arcade text-trash-gray font-bold uppercase mt-1 block">Pattern: {prob.pattern}</span>
|
| 249 |
+
</div>
|
| 250 |
+
<span className={`text-[8px] font-arcade font-bold px-2 py-0.5 border-2 border-ink-black ${
|
| 251 |
+
prob.difficulty === 'Easy' ? 'bg-grass-green text-ink-black' :
|
| 252 |
+
prob.difficulty === 'Medium' ? 'bg-secondary-container text-ink-black' :
|
| 253 |
+
'bg-highlight-pink text-white'
|
| 254 |
+
}`}>
|
| 255 |
+
{prob.difficulty.toUpperCase()}
|
| 256 |
+
</span>
|
| 257 |
+
</div>
|
| 258 |
+
|
| 259 |
+
<div className="flex gap-4 text-xs font-bold text-ink-black">
|
| 260 |
+
<div>Time: <code className="bg-[#1E293B] text-[#4ADE80] font-code-mono px-1.5 py-0.5 border border-ink-black">{prob.optimal_time}</code></div>
|
| 261 |
+
<div>Space: <code className="bg-[#1E293B] text-[#4ADE80] font-code-mono px-1.5 py-0.5 border border-ink-black">{prob.optimal_space}</code></div>
|
| 262 |
+
</div>
|
| 263 |
+
|
| 264 |
+
<div className="flex flex-col sm:flex-row gap-2 pt-2 border-t border-slate-200">
|
| 265 |
+
<a
|
| 266 |
+
href={prob.google_doc_url}
|
| 267 |
+
target="_blank"
|
| 268 |
+
rel="noreferrer"
|
| 269 |
+
className="flex-1 flex items-center justify-center gap-1.5 py-2 border-[3px] border-ink-black bg-white hover:bg-slate-100 text-xs font-bold text-ink-black shadow-[2px_2px_0px_0px_#1E293B] active:translate-y-0.5 active:shadow-none transition-all cursor-pointer text-center"
|
| 270 |
+
>
|
| 271 |
+
<ExternalLink className="w-3.5 h-3.5" />
|
| 272 |
+
Google Doc
|
| 273 |
+
</a>
|
| 274 |
+
<button
|
| 275 |
+
onClick={() => {
|
| 276 |
+
onSelectProblemForReview(prob.id);
|
| 277 |
+
setSelectedNum(null);
|
| 278 |
+
}}
|
| 279 |
+
className="flex-1 flex items-center justify-center gap-1.5 py-2 border-[3px] border-ink-black bg-[#ffe24c] hover:bg-yellow-300 text-xs font-bold text-ink-black shadow-[2px_2px_0px_0px_#1E293B] active:translate-y-0.5 active:shadow-none transition-all cursor-pointer text-center"
|
| 280 |
+
>
|
| 281 |
+
<Star className="w-3.5 h-3.5" />
|
| 282 |
+
Review Problem
|
| 283 |
+
</button>
|
| 284 |
+
</div>
|
| 285 |
+
</div>
|
| 286 |
+
))}
|
| 287 |
+
</div>
|
| 288 |
+
) : (
|
| 289 |
+
<div className="p-4 bg-white border-[3px] border-ink-black shadow-[3px_3px_0px_0px_rgba(30,41,59,1)] space-y-4">
|
| 290 |
+
<div className="flex items-start gap-3 text-xs font-bold text-ink-black leading-relaxed">
|
| 291 |
+
<Info className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
|
| 292 |
+
<div>
|
| 293 |
+
<h4 className="font-bold text-ink-black m-0">No Local Note Synced</h4>
|
| 294 |
+
<p className="text-[10px] text-trash-gray mt-1 leading-normal font-semibold">
|
| 295 |
+
You haven't synced notes for LeetCode #{selectedNum} yet. Click "Sync Google Drive" to import your progress notes.
|
| 296 |
+
</p>
|
| 297 |
+
</div>
|
| 298 |
+
</div>
|
| 299 |
+
|
| 300 |
+
<div className="space-y-2 pt-2 border-t border-slate-200">
|
| 301 |
+
<span className="text-[8px] font-arcade text-trash-gray uppercase font-bold block">External Search Shortcuts:</span>
|
| 302 |
+
<a
|
| 303 |
+
href={`https://leetcode.com/problemset/?search=${selectedNum}`}
|
| 304 |
+
target="_blank"
|
| 305 |
+
rel="noreferrer"
|
| 306 |
+
className="w-full flex items-center justify-between p-2.5 bg-white border-[3px] border-ink-black text-xs font-bold text-ink-black hover:bg-slate-100 shadow-[2px_2px_0px_0px_#1E293B] active:translate-y-0.5 active:shadow-none transition-all cursor-pointer"
|
| 307 |
+
>
|
| 308 |
+
<span className="flex items-center gap-2">
|
| 309 |
+
<ExternalLink className="w-4 h-4 text-emerald-500" />
|
| 310 |
+
Search LeetCode
|
| 311 |
+
</span>
|
| 312 |
+
<ArrowLeft className="w-3.5 h-3.5 rotate-180 text-trash-gray" />
|
| 313 |
+
</a>
|
| 314 |
+
<a
|
| 315 |
+
href={`https://www.google.com/search?q=leetcode+${selectedNum}`}
|
| 316 |
+
target="_blank"
|
| 317 |
+
rel="noreferrer"
|
| 318 |
+
className="w-full flex items-center justify-between p-2.5 bg-white border-[3px] border-ink-black text-xs font-bold text-ink-black hover:bg-slate-100 shadow-[2px_2px_0px_0px_#1E293B] active:translate-y-0.5 active:shadow-none transition-all cursor-pointer"
|
| 319 |
+
>
|
| 320 |
+
<span className="flex items-center gap-2">
|
| 321 |
+
<Search className="w-4 h-4 text-primary" />
|
| 322 |
+
Search Google
|
| 323 |
+
</span>
|
| 324 |
+
<ArrowLeft className="w-3.5 h-3.5 rotate-180 text-trash-gray" />
|
| 325 |
+
</a>
|
| 326 |
+
</div>
|
| 327 |
+
</div>
|
| 328 |
+
)}
|
| 329 |
+
|
| 330 |
+
{/* Completion Toggle */}
|
| 331 |
+
<div className="mt-4 pt-3 border-t-2 border-dashed border-ink-black flex flex-col gap-2">
|
| 332 |
+
<span className="text-[8px] font-arcade text-trash-gray font-bold uppercase block">Quick Action</span>
|
| 333 |
+
|
| 334 |
+
<button
|
| 335 |
+
onClick={() => handleToggleCompletion(selectedNum)}
|
| 336 |
+
disabled={toggleLoading}
|
| 337 |
+
className={`w-full py-3 border-[3px] border-ink-black font-arcade text-[10px] font-bold tracking-wide uppercase flex items-center justify-center gap-2 cursor-pointer transition-all shadow-[3px_3px_0px_0px_#1E293B] active:translate-y-0.5 active:shadow-none disabled:opacity-50 ${
|
| 338 |
+
completions.has(selectedNum)
|
| 339 |
+
? 'bg-red-100 text-red-600 hover:bg-red-200'
|
| 340 |
+
: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200'
|
| 341 |
+
}`}
|
| 342 |
+
>
|
| 343 |
+
{completions.has(selectedNum) ? (
|
| 344 |
+
<>
|
| 345 |
+
<XCircle className="w-4.5 h-4.5" />
|
| 346 |
+
UNMARK AS COMPLETED
|
| 347 |
+
</>
|
| 348 |
+
) : (
|
| 349 |
+
<>
|
| 350 |
+
<CheckCircle className="w-4.5 h-4.5" />
|
| 351 |
+
MARK AS COMPLETED
|
| 352 |
+
</>
|
| 353 |
+
)}
|
| 354 |
+
</button>
|
| 355 |
+
</div>
|
| 356 |
+
</div>
|
| 357 |
+
</div>
|
| 358 |
+
</div>
|
| 359 |
+
)}
|
| 360 |
+
</div>
|
| 361 |
+
);
|
| 362 |
+
}
|
algospaced-ui/src/components/MySQLPlayground.tsx
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect } from 'react';
|
| 2 |
+
import { supabase } from '../supabaseClient';
|
| 3 |
+
|
| 4 |
+
interface MySQLStatusData {
|
| 5 |
+
active?: boolean;
|
| 6 |
+
title: string;
|
| 7 |
+
objective: string;
|
| 8 |
+
points: number;
|
| 9 |
+
challenge_type: string;
|
| 10 |
+
schema: Record<string, Array<{ name: string; type: string }>>;
|
| 11 |
+
completed: boolean;
|
| 12 |
+
total_score: number;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export default function MySQLPlayground() {
|
| 16 |
+
const [status, setStatus] = useState<MySQLStatusData | null>(null);
|
| 17 |
+
const [query, setQuery] = useState('');
|
| 18 |
+
const [running, setRunning] = useState(false);
|
| 19 |
+
const [loading, setLoading] = useState(true);
|
| 20 |
+
const [error, setError] = useState('');
|
| 21 |
+
|
| 22 |
+
const [results, setResults] = useState<{
|
| 23 |
+
columns: string[];
|
| 24 |
+
rows: any[];
|
| 25 |
+
rowsAffected: number;
|
| 26 |
+
isCorrect?: boolean;
|
| 27 |
+
} | null>(null);
|
| 28 |
+
|
| 29 |
+
const fetchStatus = async () => {
|
| 30 |
+
setLoading(true);
|
| 31 |
+
setError('');
|
| 32 |
+
try {
|
| 33 |
+
const { data: { session } } = await supabase.auth.getSession();
|
| 34 |
+
if (!session) {
|
| 35 |
+
setError('Unauthenticated session');
|
| 36 |
+
return;
|
| 37 |
+
}
|
| 38 |
+
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/challenges/mysql/status`, {
|
| 39 |
+
headers: { 'Authorization': `Bearer ${session.access_token}` }
|
| 40 |
+
});
|
| 41 |
+
if (res.ok) {
|
| 42 |
+
const data = await res.json();
|
| 43 |
+
if (data.active) {
|
| 44 |
+
setStatus(data);
|
| 45 |
+
} else {
|
| 46 |
+
setStatus(null);
|
| 47 |
+
}
|
| 48 |
+
} else {
|
| 49 |
+
setError('Failed to fetch playground status.');
|
| 50 |
+
}
|
| 51 |
+
} catch {
|
| 52 |
+
setError('Connection timed out.');
|
| 53 |
+
} finally {
|
| 54 |
+
setLoading(false);
|
| 55 |
+
}
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
const handleInit = async () => {
|
| 59 |
+
setLoading(true);
|
| 60 |
+
setError('');
|
| 61 |
+
setResults(null);
|
| 62 |
+
try {
|
| 63 |
+
const { data: { session } } = await supabase.auth.getSession();
|
| 64 |
+
if (!session) {
|
| 65 |
+
setError('Unauthenticated session');
|
| 66 |
+
return;
|
| 67 |
+
}
|
| 68 |
+
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/challenges/mysql/init`, {
|
| 69 |
+
method: 'POST',
|
| 70 |
+
headers: { 'Authorization': `Bearer ${session.access_token}` }
|
| 71 |
+
});
|
| 72 |
+
if (res.ok) {
|
| 73 |
+
const data = await res.json();
|
| 74 |
+
setStatus({
|
| 75 |
+
active: true,
|
| 76 |
+
title: data.title,
|
| 77 |
+
objective: data.objective,
|
| 78 |
+
points: data.points,
|
| 79 |
+
schema: data.schema,
|
| 80 |
+
challenge_type: data.challenge_type,
|
| 81 |
+
completed: false,
|
| 82 |
+
total_score: data.total_score
|
| 83 |
+
});
|
| 84 |
+
} else {
|
| 85 |
+
setError('Failed to initialize mock database playground.');
|
| 86 |
+
}
|
| 87 |
+
} catch {
|
| 88 |
+
setError('Server connection error.');
|
| 89 |
+
} finally {
|
| 90 |
+
setLoading(false);
|
| 91 |
+
}
|
| 92 |
+
};
|
| 93 |
+
|
| 94 |
+
const handleRunQuery = async () => {
|
| 95 |
+
if (!query.trim()) return;
|
| 96 |
+
setRunning(true);
|
| 97 |
+
setError('');
|
| 98 |
+
setResults(null);
|
| 99 |
+
try {
|
| 100 |
+
const { data: { session } } = await supabase.auth.getSession();
|
| 101 |
+
if (!session) {
|
| 102 |
+
setError('Unauthenticated session');
|
| 103 |
+
return;
|
| 104 |
+
}
|
| 105 |
+
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/challenges/mysql/query`, {
|
| 106 |
+
method: 'POST',
|
| 107 |
+
headers: {
|
| 108 |
+
'Content-Type': 'application/json',
|
| 109 |
+
'Authorization': `Bearer ${session.access_token}`
|
| 110 |
+
},
|
| 111 |
+
body: JSON.stringify({ query })
|
| 112 |
+
});
|
| 113 |
+
if (res.ok) {
|
| 114 |
+
const data = await res.json();
|
| 115 |
+
if (data.success) {
|
| 116 |
+
setResults({
|
| 117 |
+
columns: data.columns,
|
| 118 |
+
rows: data.rows,
|
| 119 |
+
rowsAffected: data.rows_affected,
|
| 120 |
+
isCorrect: data.is_correct
|
| 121 |
+
});
|
| 122 |
+
if (data.is_correct) {
|
| 123 |
+
setStatus(prev => prev ? { ...prev, completed: true, total_score: data.total_score } : null);
|
| 124 |
+
}
|
| 125 |
+
if (data.schema) {
|
| 126 |
+
setStatus(prev => prev ? { ...prev, schema: data.schema } : null);
|
| 127 |
+
}
|
| 128 |
+
} else {
|
| 129 |
+
setError(data.error);
|
| 130 |
+
}
|
| 131 |
+
} else {
|
| 132 |
+
setError('Database server query failure.');
|
| 133 |
+
}
|
| 134 |
+
} catch {
|
| 135 |
+
setError('Query connection lost.');
|
| 136 |
+
} finally {
|
| 137 |
+
setRunning(false);
|
| 138 |
+
}
|
| 139 |
+
};
|
| 140 |
+
|
| 141 |
+
useEffect(() => {
|
| 142 |
+
fetchStatus();
|
| 143 |
+
}, []);
|
| 144 |
+
|
| 145 |
+
if (loading) {
|
| 146 |
+
return (
|
| 147 |
+
<div className="w-full h-full bg-paper-white p-6 flex flex-col items-center justify-center font-arcade text-[10px]">
|
| 148 |
+
<div className="animate-bounce mb-2">INITIALIZING SQL PLAYGROUND...</div>
|
| 149 |
+
<div className="w-48 h-4 bg-white border-[3px] border-ink-black overflow-hidden relative">
|
| 150 |
+
<div className="absolute inset-y-0 left-0 bg-[#0ea5e9] w-1/2 animate-pulse border-r-2 border-ink-black" />
|
| 151 |
+
</div>
|
| 152 |
+
</div>
|
| 153 |
+
);
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
// Dashboard state: no active database initialized
|
| 157 |
+
if (!status) {
|
| 158 |
+
return (
|
| 159 |
+
<div className="w-full h-full bg-paper-white p-6 flex flex-col justify-between items-center text-center text-ink-black text-xs md:text-sm font-sans select-none">
|
| 160 |
+
<div className="flex-1 flex flex-col justify-center items-center gap-4">
|
| 161 |
+
<div className="w-16 h-16 bg-white border-[3px] border-ink-black flex items-center justify-center shadow-[4px_4px_0_0_#1E293B]">
|
| 162 |
+
<span className="material-symbols-outlined text-4xl text-[#0ea5e9]" style={{ fontVariationSettings: "'FILL' 1" }}>
|
| 163 |
+
database
|
| 164 |
+
</span>
|
| 165 |
+
</div>
|
| 166 |
+
<h3 className="font-headline-md font-bold text-lg">Interactive MySQL Playground</h3>
|
| 167 |
+
<p className="max-w-md text-slate-600 leading-relaxed font-semibold">
|
| 168 |
+
Test and run relational queries against isolated schemas dynamically built by the AI generator. Includes full support for:
|
| 169 |
+
</p>
|
| 170 |
+
<div className="flex flex-wrap justify-center gap-2 font-mono text-[10px] text-slate-700 font-bold uppercase mt-1">
|
| 171 |
+
<span className="bg-blue-100 border border-blue-400 px-2 py-0.5">SELECT / JOIN</span>
|
| 172 |
+
<span className="bg-emerald-100 border border-emerald-400 px-2 py-0.5">UPDATE / DELETE</span>
|
| 173 |
+
<span className="bg-purple-100 border border-purple-400 px-2 py-0.5">WINDOW FUNCTIONS</span>
|
| 174 |
+
</div>
|
| 175 |
+
</div>
|
| 176 |
+
|
| 177 |
+
<div className="w-full border-t border-slate-200 pt-4 flex flex-col gap-2 shrink-0">
|
| 178 |
+
<button
|
| 179 |
+
onClick={handleInit}
|
| 180 |
+
className="w-full py-2.5 bg-[#0ea5e9] hover:bg-sky-400 text-white font-window-title font-bold text-xs uppercase border-[3px] border-ink-black shadow-[4px_4px_0px_rgba(30,41,59,1)] active:translate-y-0.5 active:shadow-none transition-all cursor-pointer text-center"
|
| 181 |
+
>
|
| 182 |
+
CREATE NEW DATABASE INSTANCE
|
| 183 |
+
</button>
|
| 184 |
+
</div>
|
| 185 |
+
</div>
|
| 186 |
+
);
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
return (
|
| 190 |
+
<div className="w-full h-full bg-paper-white text-ink-black flex flex-col overflow-hidden text-xs md:text-sm">
|
| 191 |
+
{/* Play Workspace Scrollable Body */}
|
| 192 |
+
<div className="flex-1 overflow-y-auto p-4 custom-scrollbar flex flex-col gap-4">
|
| 193 |
+
|
| 194 |
+
{/* Objectives Badge Header */}
|
| 195 |
+
<div className="flex justify-between items-center bg-slate-100 border-[3px] border-ink-black p-3 shrink-0">
|
| 196 |
+
<div className="flex gap-2">
|
| 197 |
+
<span className="bg-blue-100 text-blue-800 px-2 py-0.5 border-2 border-ink-black font-bold text-[9px] uppercase">
|
| 198 |
+
{status.challenge_type}
|
| 199 |
+
</span>
|
| 200 |
+
<span className="bg-yellow-100 text-yellow-800 px-2 py-0.5 border-2 border-ink-black font-bold text-[9px]">
|
| 201 |
+
+{status.points} PTS
|
| 202 |
+
</span>
|
| 203 |
+
{status.completed ? (
|
| 204 |
+
<span className="bg-emerald-500 text-white px-2 py-0.5 border-2 border-ink-black font-bold text-[9px]">
|
| 205 |
+
SOLVED
|
| 206 |
+
</span>
|
| 207 |
+
) : (
|
| 208 |
+
<span className="bg-rose-500 text-white px-2 py-0.5 border-2 border-ink-black font-bold text-[9px]">
|
| 209 |
+
UNSOLVED
|
| 210 |
+
</span>
|
| 211 |
+
)}
|
| 212 |
+
</div>
|
| 213 |
+
<div className="font-arcade text-[8px] text-sky-700 font-bold bg-white border-2 border-ink-black px-2 py-0.5 shadow-[1px_1px_0_0_#1E293B]">
|
| 214 |
+
SCORE: {status.total_score}
|
| 215 |
+
</div>
|
| 216 |
+
</div>
|
| 217 |
+
|
| 218 |
+
{/* Objective banner */}
|
| 219 |
+
<div className="border-[3px] border-yellow-400 bg-yellow-50 p-3 rounded font-sans leading-relaxed text-xs">
|
| 220 |
+
<h4 className="font-arcade text-[9px] text-yellow-800 mb-1">MISSION OBJECTIVE</h4>
|
| 221 |
+
<p className="font-bold text-slate-800">{status.objective}</p>
|
| 222 |
+
</div>
|
| 223 |
+
|
| 224 |
+
{/* Schema Tree */}
|
| 225 |
+
<div className="border-[3px] border-ink-black bg-white p-3">
|
| 226 |
+
<div className="font-bold text-[10px] border-b border-slate-300 pb-1 mb-2 flex items-center gap-1.5">
|
| 227 |
+
<span className="material-symbols-outlined text-sm">schema</span>
|
| 228 |
+
<span>SCHEMA EXPLORER (MOCK INSTANCE)</span>
|
| 229 |
+
</div>
|
| 230 |
+
<div className="grid grid-cols-2 gap-3 max-h-[140px] overflow-y-auto custom-scrollbar font-mono text-[10px]">
|
| 231 |
+
{Object.entries(status.schema).map(([tableName, cols]) => (
|
| 232 |
+
<div key={tableName} className="border border-slate-300 p-2 bg-slate-50">
|
| 233 |
+
<div className="font-bold text-blue-700 border-b border-slate-200 pb-0.5 mb-1 flex items-center gap-1">
|
| 234 |
+
<span className="material-symbols-outlined text-[12px]">table_chart</span>
|
| 235 |
+
{tableName}
|
| 236 |
+
</div>
|
| 237 |
+
<ul className="space-y-0.5 text-slate-600 text-[9px]">
|
| 238 |
+
{cols.map((col, cIdx) => (
|
| 239 |
+
<li key={cIdx} className="flex justify-between">
|
| 240 |
+
<span>{col.name}</span>
|
| 241 |
+
<span className="text-slate-400 italic text-[8px]">{col.type}</span>
|
| 242 |
+
</li>
|
| 243 |
+
))}
|
| 244 |
+
</ul>
|
| 245 |
+
</div>
|
| 246 |
+
))}
|
| 247 |
+
</div>
|
| 248 |
+
</div>
|
| 249 |
+
|
| 250 |
+
{/* Query Input Editor */}
|
| 251 |
+
<div className="flex flex-col min-h-[120px]">
|
| 252 |
+
<div className="font-bold text-[10px] mb-1 tracking-wide uppercase flex items-center gap-1">
|
| 253 |
+
<span className="material-symbols-outlined text-sm text-sky-600">terminal</span>
|
| 254 |
+
<span>Enter SQL Query:</span>
|
| 255 |
+
</div>
|
| 256 |
+
<textarea
|
| 257 |
+
value={query}
|
| 258 |
+
onChange={(e) => setQuery(e.target.value)}
|
| 259 |
+
className="flex-1 w-full bg-slate-900 text-[#4ADE80] font-mono p-3 border-[3px] border-ink-black rounded outline-none focus:ring-2 focus:ring-sky-500 text-xs leading-relaxed resize-none"
|
| 260 |
+
placeholder="SELECT * FROM customers WHERE score > 100;"
|
| 261 |
+
spellCheck="false"
|
| 262 |
+
/>
|
| 263 |
+
</div>
|
| 264 |
+
|
| 265 |
+
{/* Run Button bar */}
|
| 266 |
+
<div className="flex gap-3">
|
| 267 |
+
<button
|
| 268 |
+
onClick={handleInit}
|
| 269 |
+
className="flex-1 bg-white hover:bg-slate-100 text-ink-black border-[3px] border-ink-black font-bold font-window-title text-xs py-2 shadow-[3px_3px_0px_rgba(30,41,59,1)] active:translate-y-0.5 active:shadow-none cursor-pointer"
|
| 270 |
+
>
|
| 271 |
+
NEW PROBLEM
|
| 272 |
+
</button>
|
| 273 |
+
<button
|
| 274 |
+
disabled={running}
|
| 275 |
+
onClick={handleRunQuery}
|
| 276 |
+
className="flex-[2] bg-highlight-pink text-white font-window-title font-bold py-2 border-[3px] border-ink-black shadow-[3px_3px_0px_rgba(30,41,59,1)] active:translate-y-0.5 active:shadow-none hover:bg-pink-400 transition-all disabled:opacity-50 cursor-pointer text-center text-xs"
|
| 277 |
+
>
|
| 278 |
+
{running ? 'EXECUTING SQL...' : 'EXECUTE SQL QUERY'}
|
| 279 |
+
</button>
|
| 280 |
+
</div>
|
| 281 |
+
|
| 282 |
+
{/* Query Error Console */}
|
| 283 |
+
{error && (
|
| 284 |
+
<div className="border-[3px] border-red-500 bg-red-50 text-red-800 p-3 font-mono text-xs whitespace-pre-wrap">
|
| 285 |
+
<div className="font-bold mb-1">SQL RUNTIME / SYNTAX ERROR:</div>
|
| 286 |
+
{error}
|
| 287 |
+
</div>
|
| 288 |
+
)}
|
| 289 |
+
|
| 290 |
+
{/* Tabular Result Grid */}
|
| 291 |
+
{results && (
|
| 292 |
+
<div className="border-[3px] border-ink-black bg-white p-3 flex flex-col">
|
| 293 |
+
<div className="font-bold text-[10px] border-b border-slate-300 pb-1 mb-2 flex justify-between items-center">
|
| 294 |
+
<span>QUERY OUTPUT BUFFER</span>
|
| 295 |
+
{results.isCorrect && (
|
| 296 |
+
<span className="text-green-600 font-bold animate-bounce text-[9px]">
|
| 297 |
+
✓ MISSION ACHIEVED!
|
| 298 |
+
</span>
|
| 299 |
+
)}
|
| 300 |
+
</div>
|
| 301 |
+
|
| 302 |
+
<div className="text-[9px] text-slate-500 mb-2 font-mono">
|
| 303 |
+
Rows Affected: {results.rowsAffected} | Total Returned: {results.rows.length}
|
| 304 |
+
</div>
|
| 305 |
+
|
| 306 |
+
{results.rows.length > 0 ? (
|
| 307 |
+
<div className="overflow-x-auto border border-slate-300 max-h-[180px] custom-scrollbar">
|
| 308 |
+
<table className="border-collapse w-full font-mono text-[9px] min-w-full">
|
| 309 |
+
<thead>
|
| 310 |
+
<tr className="bg-slate-100 border-b border-slate-300">
|
| 311 |
+
{results.columns.map((col, idx) => (
|
| 312 |
+
<th key={idx} className="border-r border-slate-300 p-1.5 text-left font-bold text-slate-700">
|
| 313 |
+
{col}
|
| 314 |
+
</th>
|
| 315 |
+
))}
|
| 316 |
+
</tr>
|
| 317 |
+
</thead>
|
| 318 |
+
<tbody className="divide-y divide-slate-200">
|
| 319 |
+
{results.rows.map((row, rIdx) => (
|
| 320 |
+
<tr key={rIdx} className="hover:bg-slate-50">
|
| 321 |
+
{results.columns.map((col, cIdx) => (
|
| 322 |
+
<td key={cIdx} className="border-r border-slate-200 p-1.5 text-slate-800 whitespace-nowrap">
|
| 323 |
+
{row[col] !== null ? String(row[col]) : <span className="text-slate-300 italic">NULL</span>}
|
| 324 |
+
</td>
|
| 325 |
+
))}
|
| 326 |
+
</tr>
|
| 327 |
+
))}
|
| 328 |
+
</tbody>
|
| 329 |
+
</table>
|
| 330 |
+
</div>
|
| 331 |
+
) : (
|
| 332 |
+
<div className="p-3 border border-dashed border-slate-300 text-center text-slate-400 italic font-sans text-xs">
|
| 333 |
+
Query executed successfully. Result set is empty (or modification completed).
|
| 334 |
+
</div>
|
| 335 |
+
)}
|
| 336 |
+
</div>
|
| 337 |
+
)}
|
| 338 |
+
</div>
|
| 339 |
+
</div>
|
| 340 |
+
);
|
| 341 |
+
}
|
algospaced-ui/src/components/PythonChallenge.tsx
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect } from 'react';
|
| 2 |
+
import { supabase } from '../supabaseClient';
|
| 3 |
+
|
| 4 |
+
interface PythonChallengeData {
|
| 5 |
+
title: string;
|
| 6 |
+
description: string;
|
| 7 |
+
difficulty: string;
|
| 8 |
+
points: number;
|
| 9 |
+
starter_code: string;
|
| 10 |
+
completed: boolean;
|
| 11 |
+
total_score: number;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export default function PythonChallenge() {
|
| 15 |
+
const [challenge, setChallenge] = useState<PythonChallengeData | null>(null);
|
| 16 |
+
const [code, setCode] = useState('');
|
| 17 |
+
const [submitting, setSubmitting] = useState(false);
|
| 18 |
+
const [results, setResults] = useState<any[]>([]);
|
| 19 |
+
const [error, setError] = useState('');
|
| 20 |
+
const [loading, setLoading] = useState(true);
|
| 21 |
+
const [globalScore, setGlobalScore] = useState(0);
|
| 22 |
+
|
| 23 |
+
const fetchChallenge = async () => {
|
| 24 |
+
setLoading(true);
|
| 25 |
+
setError('');
|
| 26 |
+
try {
|
| 27 |
+
const { data: { session } } = await supabase.auth.getSession();
|
| 28 |
+
if (!session) {
|
| 29 |
+
setError('Unauthenticated session');
|
| 30 |
+
return;
|
| 31 |
+
}
|
| 32 |
+
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/challenges/python/daily`, {
|
| 33 |
+
headers: { 'Authorization': `Bearer ${session.access_token}` }
|
| 34 |
+
});
|
| 35 |
+
if (res.ok) {
|
| 36 |
+
const data = await res.json();
|
| 37 |
+
setChallenge(data);
|
| 38 |
+
setCode(data.starter_code);
|
| 39 |
+
setGlobalScore(data.total_score);
|
| 40 |
+
} else {
|
| 41 |
+
setError('Failed to load challenge from server.');
|
| 42 |
+
}
|
| 43 |
+
} catch {
|
| 44 |
+
setError('Connection timed out.');
|
| 45 |
+
} finally {
|
| 46 |
+
setLoading(false);
|
| 47 |
+
}
|
| 48 |
+
};
|
| 49 |
+
|
| 50 |
+
useEffect(() => {
|
| 51 |
+
fetchChallenge();
|
| 52 |
+
}, []);
|
| 53 |
+
|
| 54 |
+
const handleSubmit = async () => {
|
| 55 |
+
setSubmitting(true);
|
| 56 |
+
setError('');
|
| 57 |
+
setResults([]);
|
| 58 |
+
try {
|
| 59 |
+
const { data: { session } } = await supabase.auth.getSession();
|
| 60 |
+
if (!session) {
|
| 61 |
+
setError('Unauthenticated session');
|
| 62 |
+
return;
|
| 63 |
+
}
|
| 64 |
+
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/challenges/python/submit`, {
|
| 65 |
+
method: 'POST',
|
| 66 |
+
headers: {
|
| 67 |
+
'Content-Type': 'application/json',
|
| 68 |
+
'Authorization': `Bearer ${session.access_token}`
|
| 69 |
+
},
|
| 70 |
+
body: JSON.stringify({ code })
|
| 71 |
+
});
|
| 72 |
+
if (res.ok) {
|
| 73 |
+
const data = await res.json();
|
| 74 |
+
if (data.success) {
|
| 75 |
+
setResults(data.results);
|
| 76 |
+
if (data.all_passed) {
|
| 77 |
+
setGlobalScore(data.total_score);
|
| 78 |
+
setChallenge(prev => prev ? { ...prev, completed: true } : null);
|
| 79 |
+
}
|
| 80 |
+
} else {
|
| 81 |
+
setError(data.error);
|
| 82 |
+
}
|
| 83 |
+
} else {
|
| 84 |
+
setError('Submission failed at server host.');
|
| 85 |
+
}
|
| 86 |
+
} catch {
|
| 87 |
+
setError('Network connection lost.');
|
| 88 |
+
} finally {
|
| 89 |
+
setSubmitting(false);
|
| 90 |
+
}
|
| 91 |
+
};
|
| 92 |
+
|
| 93 |
+
if (loading) {
|
| 94 |
+
return (
|
| 95 |
+
<div className="w-full h-full bg-paper-white p-6 flex flex-col items-center justify-center font-arcade text-[10px]">
|
| 96 |
+
<div className="animate-bounce mb-2">LOADING CHALLENGE...</div>
|
| 97 |
+
<div className="w-48 h-4 bg-white border-[3px] border-ink-black overflow-hidden relative">
|
| 98 |
+
<div className="absolute inset-y-0 left-0 bg-[#9d4edd] w-2/3 animate-pulse border-r-2 border-ink-black" />
|
| 99 |
+
</div>
|
| 100 |
+
</div>
|
| 101 |
+
);
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
if (!challenge) {
|
| 105 |
+
return (
|
| 106 |
+
<div className="w-full h-full bg-paper-white p-6 flex flex-col items-center justify-center font-sans text-center gap-3">
|
| 107 |
+
<span className="material-symbols-outlined text-red-500 text-4xl">error</span>
|
| 108 |
+
<div className="font-bold text-ink-black text-sm">{error || 'No active challenge found.'}</div>
|
| 109 |
+
<button
|
| 110 |
+
onClick={fetchChallenge}
|
| 111 |
+
className="px-3 py-1.5 bg-yellow-400 hover:bg-yellow-300 border-[3px] border-ink-black text-xs font-bold font-window-title cursor-pointer shadow-[2px_2px_0px_rgba(0,0,0,1)] active:translate-y-0.5 active:shadow-none"
|
| 112 |
+
>
|
| 113 |
+
RETRY
|
| 114 |
+
</button>
|
| 115 |
+
</div>
|
| 116 |
+
);
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
return (
|
| 120 |
+
<div className="w-full h-full bg-paper-white text-ink-black flex flex-col overflow-hidden text-xs md:text-sm">
|
| 121 |
+
{/* Workspace Body */}
|
| 122 |
+
<div className="flex-1 overflow-y-auto p-4 custom-scrollbar flex flex-col gap-4">
|
| 123 |
+
{/* Info Header */}
|
| 124 |
+
<div className="flex justify-between items-center bg-slate-100 border-[3px] border-ink-black p-3">
|
| 125 |
+
<div className="flex gap-2">
|
| 126 |
+
<span className={`px-2 py-0.5 border-2 border-ink-black font-bold uppercase text-[9px] ${
|
| 127 |
+
challenge.difficulty === 'Easy' ? 'bg-green-200 text-green-800' :
|
| 128 |
+
challenge.difficulty === 'Medium' ? 'bg-yellow-200 text-yellow-800' :
|
| 129 |
+
'bg-red-200 text-red-800'
|
| 130 |
+
}`}>
|
| 131 |
+
{challenge.difficulty}
|
| 132 |
+
</span>
|
| 133 |
+
<span className="bg-blue-100 text-blue-800 px-2 py-0.5 border-2 border-ink-black font-bold text-[9px]">
|
| 134 |
+
+{challenge.points} PTS
|
| 135 |
+
</span>
|
| 136 |
+
{challenge.completed ? (
|
| 137 |
+
<span className="bg-emerald-500 text-white px-2 py-0.5 border-2 border-ink-black font-bold text-[9px]">
|
| 138 |
+
COMPLETED
|
| 139 |
+
</span>
|
| 140 |
+
) : (
|
| 141 |
+
<span className="bg-rose-500 text-white px-2 py-0.5 border-2 border-ink-black font-bold text-[9px]">
|
| 142 |
+
INCOMPLETE
|
| 143 |
+
</span>
|
| 144 |
+
)}
|
| 145 |
+
</div>
|
| 146 |
+
<div className="font-arcade text-[8px] text-purple-700 font-bold bg-white border-2 border-ink-black px-2 py-0.5 shadow-[1px_1px_0_0_#1E293B]">
|
| 147 |
+
TOTAL SCORE: {globalScore}
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
|
| 151 |
+
{/* Description */}
|
| 152 |
+
<div className="border-2 border-ink-black bg-slate-50 p-3 rounded font-sans leading-relaxed text-xs">
|
| 153 |
+
<h3 className="font-bold border-b border-slate-300 pb-1 mb-2 uppercase tracking-wide text-primary-purple flex items-center gap-1">
|
| 154 |
+
<span className="material-symbols-outlined text-sm">description</span>
|
| 155 |
+
Problem: {challenge.title}
|
| 156 |
+
</h3>
|
| 157 |
+
<p className="whitespace-pre-wrap">{challenge.description}</p>
|
| 158 |
+
</div>
|
| 159 |
+
|
| 160 |
+
{/* Code Editor */}
|
| 161 |
+
<div className="flex flex-col flex-1 min-h-[220px]">
|
| 162 |
+
<div className="flex justify-between items-center mb-1">
|
| 163 |
+
<span className="font-bold text-[10px] tracking-wide uppercase flex items-center gap-1">
|
| 164 |
+
<span className="material-symbols-outlined text-sm text-purple-600">code</span>
|
| 165 |
+
Write Python Solution:
|
| 166 |
+
</span>
|
| 167 |
+
<button
|
| 168 |
+
onClick={() => setCode(challenge.starter_code)}
|
| 169 |
+
className="text-[9px] underline text-blue-600 hover:text-blue-800 cursor-pointer font-sans"
|
| 170 |
+
>
|
| 171 |
+
Reset Starter Code
|
| 172 |
+
</button>
|
| 173 |
+
</div>
|
| 174 |
+
<textarea
|
| 175 |
+
value={code}
|
| 176 |
+
onChange={(e) => setCode(e.target.value)}
|
| 177 |
+
className="flex-1 w-full bg-slate-900 text-[#4ADE80] font-mono p-3 border-[3px] border-ink-black rounded outline-none focus:ring-2 focus:ring-primary-purple text-xs leading-relaxed resize-none"
|
| 178 |
+
placeholder="# Write your python code here"
|
| 179 |
+
spellCheck="false"
|
| 180 |
+
/>
|
| 181 |
+
</div>
|
| 182 |
+
|
| 183 |
+
{/* Submit Bar */}
|
| 184 |
+
<div className="shrink-0">
|
| 185 |
+
<button
|
| 186 |
+
disabled={submitting}
|
| 187 |
+
onClick={handleSubmit}
|
| 188 |
+
className="w-full bg-highlight-pink text-white font-window-title font-bold py-2 px-4 border-[3px] border-ink-black shadow-[3px_3px_0px_0px_rgba(30,41,59,1)] active:translate-y-0.5 active:shadow-none hover:bg-pink-400 transition-all disabled:opacity-50 cursor-pointer text-center text-xs"
|
| 189 |
+
>
|
| 190 |
+
{submitting ? 'EXECUTING TEST CASES...' : 'RUN SOLUTION SUBMISSION'}
|
| 191 |
+
</button>
|
| 192 |
+
</div>
|
| 193 |
+
|
| 194 |
+
{/* Error Console */}
|
| 195 |
+
{error && (
|
| 196 |
+
<div className="border-[3px] border-red-500 bg-red-50 text-red-800 p-3 font-mono text-xs whitespace-pre-wrap">
|
| 197 |
+
<div className="font-bold mb-1">COMPILER / RUNTIME ERROR:</div>
|
| 198 |
+
{error}
|
| 199 |
+
</div>
|
| 200 |
+
)}
|
| 201 |
+
|
| 202 |
+
{/* Test Results */}
|
| 203 |
+
{results.length > 0 && (
|
| 204 |
+
<div className="border-[3px] border-ink-black bg-slate-100 p-3">
|
| 205 |
+
<div className="font-bold text-[10px] border-b border-slate-300 pb-1 mb-2 uppercase">Test Case Executions:</div>
|
| 206 |
+
<div className="space-y-1.5 font-mono text-xs">
|
| 207 |
+
{results.map((res, index) => (
|
| 208 |
+
<div key={index} className="flex flex-col p-2 border border-slate-300 bg-white">
|
| 209 |
+
<div className="flex justify-between">
|
| 210 |
+
<span className="font-bold">Test Case {index + 1}:</span>
|
| 211 |
+
<span className={`font-bold uppercase ${res.status === 'passed' ? 'text-green-600' : 'text-red-600'}`}>
|
| 212 |
+
{res.status}
|
| 213 |
+
</span>
|
| 214 |
+
</div>
|
| 215 |
+
<div className="text-[10px] text-slate-500 mt-1">
|
| 216 |
+
<div>Input: {JSON.stringify(res.input)}</div>
|
| 217 |
+
{res.status === 'passed' && <div>Output: {JSON.stringify(res.got)}</div>}
|
| 218 |
+
{res.status === 'failed' && (
|
| 219 |
+
<div className="text-red-700">
|
| 220 |
+
Expected: {JSON.stringify(res.expected)} | Got: {JSON.stringify(res.got)}
|
| 221 |
+
</div>
|
| 222 |
+
)}
|
| 223 |
+
{res.status === 'error' && (
|
| 224 |
+
<div className="text-red-700 font-semibold">
|
| 225 |
+
Error: {res.error}
|
| 226 |
+
</div>
|
| 227 |
+
)}
|
| 228 |
+
</div>
|
| 229 |
+
</div>
|
| 230 |
+
))}
|
| 231 |
+
</div>
|
| 232 |
+
</div>
|
| 233 |
+
)}
|
| 234 |
+
</div>
|
| 235 |
+
</div>
|
| 236 |
+
);
|
| 237 |
+
}
|
algospaced-ui/src/components/ReviewQueue.tsx
ADDED
|
@@ -0,0 +1,683 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from 'react';
|
| 2 |
+
import { supabase } from '../supabaseClient';
|
| 3 |
+
import { Sparkles, Check, AlertTriangle, Code, Award, ExternalLink, RefreshCw, ChevronDown } from 'lucide-react';
|
| 4 |
+
import CodeMirror from '@uiw/react-codemirror';
|
| 5 |
+
import { python } from '@codemirror/lang-python';
|
| 6 |
+
|
| 7 |
+
interface ReviewQueueProps {
|
| 8 |
+
overrideProblemId: string | null;
|
| 9 |
+
onClearOverride: () => void;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export default function ReviewQueue({ overrideProblemId, onClearOverride }: ReviewQueueProps) {
|
| 13 |
+
const [reviews, setReviews] = useState<any[]>([]);
|
| 14 |
+
const [loading, setLoading] = useState(true);
|
| 15 |
+
const [submitting, setSubmitting] = useState(false);
|
| 16 |
+
const [code, setCode] = useState('');
|
| 17 |
+
const [activeTab, setActiveTab] = useState<'ai' | 'offline'>('ai');
|
| 18 |
+
|
| 19 |
+
// AI evaluation states
|
| 20 |
+
const [aiLoading, setAiLoading] = useState(false);
|
| 21 |
+
const [aiResult, setAiResult] = useState<any>(null);
|
| 22 |
+
const [aiError, setAiError] = useState<string | null>(null);
|
| 23 |
+
|
| 24 |
+
// Reference code collapse state
|
| 25 |
+
const [showRefCode, setShowRefCode] = useState(false);
|
| 26 |
+
|
| 27 |
+
// Code explanation states
|
| 28 |
+
const [explainLoading, setExplainLoading] = useState(false);
|
| 29 |
+
const [explanationText, setExplanationText] = useState<string | null>(null);
|
| 30 |
+
const [explainError, setExplainError] = useState<string | null>(null);
|
| 31 |
+
|
| 32 |
+
// Autocomplete state (persistent)
|
| 33 |
+
const [isAutocompleteEnabled, setIsAutocompleteEnabled] = useState<boolean>(() => {
|
| 34 |
+
const saved = localStorage.getItem('isAutocompleteEnabled');
|
| 35 |
+
return saved !== 'false';
|
| 36 |
+
});
|
| 37 |
+
|
| 38 |
+
const fetchQueue = async () => {
|
| 39 |
+
setLoading(true);
|
| 40 |
+
setAiResult(null);
|
| 41 |
+
setAiError(null);
|
| 42 |
+
setCode('');
|
| 43 |
+
setShowRefCode(false);
|
| 44 |
+
setExplanationText(null);
|
| 45 |
+
setExplainError(null);
|
| 46 |
+
try {
|
| 47 |
+
const { data: { session } } = await supabase.auth.getSession();
|
| 48 |
+
if (!session) return;
|
| 49 |
+
|
| 50 |
+
const headers = { 'Authorization': `Bearer ${session.access_token}` };
|
| 51 |
+
|
| 52 |
+
let url = `${import.meta.env.VITE_API_BASE_URL}/api/reviews/due`;
|
| 53 |
+
if (overrideProblemId) {
|
| 54 |
+
url = `${import.meta.env.VITE_API_BASE_URL}/api/reviews/problems/id/${overrideProblemId}`;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
const res = await fetch(url, { headers });
|
| 58 |
+
if (res.ok) {
|
| 59 |
+
const data = await res.json();
|
| 60 |
+
setReviews(data);
|
| 61 |
+
if (data && data.length > 0) {
|
| 62 |
+
const activeReview = data[0];
|
| 63 |
+
const problem = activeReview.problems;
|
| 64 |
+
if (problem && problem.starter_code) {
|
| 65 |
+
setCode(problem.starter_code);
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
} catch (err) {
|
| 70 |
+
console.error('Error fetching review queue:', err);
|
| 71 |
+
} finally {
|
| 72 |
+
setLoading(false);
|
| 73 |
+
}
|
| 74 |
+
};
|
| 75 |
+
|
| 76 |
+
useEffect(() => {
|
| 77 |
+
fetchQueue();
|
| 78 |
+
}, [overrideProblemId]);
|
| 79 |
+
|
| 80 |
+
const handleGrade = async (rating: 'Correct' | 'Mixed' | 'Incorrect') => {
|
| 81 |
+
if (reviews.length === 0) return;
|
| 82 |
+
const activeReview = reviews[0];
|
| 83 |
+
|
| 84 |
+
setSubmitting(true);
|
| 85 |
+
try {
|
| 86 |
+
const { data: { session } } = await supabase.auth.getSession();
|
| 87 |
+
if (!session) return;
|
| 88 |
+
|
| 89 |
+
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/reviews/complete`, {
|
| 90 |
+
method: 'POST',
|
| 91 |
+
headers: {
|
| 92 |
+
'Content-Type': 'application/json',
|
| 93 |
+
'Authorization': `Bearer ${session.access_token}`
|
| 94 |
+
},
|
| 95 |
+
body: JSON.stringify({
|
| 96 |
+
review_id: activeReview.id,
|
| 97 |
+
box_level: activeReview.box_level,
|
| 98 |
+
times_correct: activeReview.times_correct,
|
| 99 |
+
total_attempts: activeReview.total_attempts,
|
| 100 |
+
rating
|
| 101 |
+
})
|
| 102 |
+
});
|
| 103 |
+
|
| 104 |
+
if (res.ok) {
|
| 105 |
+
if (overrideProblemId) {
|
| 106 |
+
// If we completed the custom override card, exit override mode
|
| 107 |
+
onClearOverride();
|
| 108 |
+
} else {
|
| 109 |
+
// Refresh the daily queue
|
| 110 |
+
fetchQueue();
|
| 111 |
+
}
|
| 112 |
+
}
|
| 113 |
+
} catch (err) {
|
| 114 |
+
console.error('Error logging review completion:', err);
|
| 115 |
+
} finally {
|
| 116 |
+
setSubmitting(false);
|
| 117 |
+
}
|
| 118 |
+
};
|
| 119 |
+
|
| 120 |
+
const handleAiEvaluation = async () => {
|
| 121 |
+
if (reviews.length === 0 || !code.trim()) return;
|
| 122 |
+
const activeReview = reviews[0];
|
| 123 |
+
const problem = activeReview.problems;
|
| 124 |
+
|
| 125 |
+
setAiLoading(true);
|
| 126 |
+
setAiResult(null);
|
| 127 |
+
setAiError(null);
|
| 128 |
+
|
| 129 |
+
try {
|
| 130 |
+
const { data: { session } } = await supabase.auth.getSession();
|
| 131 |
+
if (!session) return;
|
| 132 |
+
|
| 133 |
+
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/reviews/evaluate`, {
|
| 134 |
+
method: 'POST',
|
| 135 |
+
headers: {
|
| 136 |
+
'Content-Type': 'application/json',
|
| 137 |
+
'Authorization': `Bearer ${session.access_token}`
|
| 138 |
+
},
|
| 139 |
+
body: JSON.stringify({
|
| 140 |
+
problem_name: problem.name,
|
| 141 |
+
pattern: problem.pattern,
|
| 142 |
+
optimal_time: problem.optimal_time,
|
| 143 |
+
optimal_space: problem.optimal_space,
|
| 144 |
+
code: code
|
| 145 |
+
})
|
| 146 |
+
});
|
| 147 |
+
|
| 148 |
+
if (res.ok) {
|
| 149 |
+
const result = await res.json();
|
| 150 |
+
setAiResult(result);
|
| 151 |
+
} else {
|
| 152 |
+
const errorData = await res.json();
|
| 153 |
+
setAiError(errorData.detail || 'Failed to analyze code.');
|
| 154 |
+
}
|
| 155 |
+
} catch (err) {
|
| 156 |
+
setAiError('Connection to evaluation server failed.');
|
| 157 |
+
} finally {
|
| 158 |
+
setAiLoading(false);
|
| 159 |
+
}
|
| 160 |
+
};
|
| 161 |
+
|
| 162 |
+
const handleExplainCode = async () => {
|
| 163 |
+
if (reviews.length === 0) return;
|
| 164 |
+
const activeReview = reviews[0];
|
| 165 |
+
const problem = activeReview.problems;
|
| 166 |
+
if (!problem || !problem.problem_number) return;
|
| 167 |
+
|
| 168 |
+
setExplainLoading(true);
|
| 169 |
+
setExplanationText(null);
|
| 170 |
+
setExplainError(null);
|
| 171 |
+
|
| 172 |
+
try {
|
| 173 |
+
const { data: { session } } = await supabase.auth.getSession();
|
| 174 |
+
if (!session) return;
|
| 175 |
+
|
| 176 |
+
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/reviews/explain`, {
|
| 177 |
+
method: 'POST',
|
| 178 |
+
headers: {
|
| 179 |
+
'Content-Type': 'application/json',
|
| 180 |
+
'Authorization': `Bearer ${session.access_token}`
|
| 181 |
+
},
|
| 182 |
+
body: JSON.stringify({
|
| 183 |
+
problem_number: problem.problem_number,
|
| 184 |
+
method: problem.pattern
|
| 185 |
+
})
|
| 186 |
+
});
|
| 187 |
+
|
| 188 |
+
if (res.ok) {
|
| 189 |
+
const result = await res.json();
|
| 190 |
+
setExplanationText(result.explanation);
|
| 191 |
+
} else {
|
| 192 |
+
const errorData = await res.json();
|
| 193 |
+
setExplainError(errorData.detail || 'Failed to generate code explanation.');
|
| 194 |
+
}
|
| 195 |
+
} catch (err) {
|
| 196 |
+
setExplainError('Connection to explanation server failed.');
|
| 197 |
+
} finally {
|
| 198 |
+
setExplainLoading(false);
|
| 199 |
+
}
|
| 200 |
+
};
|
| 201 |
+
|
| 202 |
+
if (loading) {
|
| 203 |
+
return (
|
| 204 |
+
<div className="w-full h-full flex items-center justify-center bg-[#fdf8e1] text-ink-black">
|
| 205 |
+
<div className="flex flex-col items-center gap-3">
|
| 206 |
+
<RefreshCw className="w-8 h-8 text-primary animate-spin" />
|
| 207 |
+
<span className="font-arcade text-[10px] tracking-wide">LOADING SYSTEM CARD...</span>
|
| 208 |
+
</div>
|
| 209 |
+
</div>
|
| 210 |
+
);
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
if (reviews.length === 0) {
|
| 214 |
+
return (
|
| 215 |
+
<div className="w-full h-full flex items-center justify-center bg-[#fdf8e1] px-6">
|
| 216 |
+
<div className="w-full max-w-md text-center space-y-6 py-12 px-8 bg-paper-white border-[3px] border-ink-black gadget-shadow relative overflow-hidden">
|
| 217 |
+
<div className="absolute top-0 left-0 right-0 h-[8px] bg-grass-green" />
|
| 218 |
+
<div className="w-16 h-16 bg-emerald-100 border-[3px] border-ink-black flex items-center justify-center mx-auto text-emerald-600 gadget-shadow">
|
| 219 |
+
<Check className="w-8 h-8 stroke-[3px]" />
|
| 220 |
+
</div>
|
| 221 |
+
<div>
|
| 222 |
+
<h2 className="text-xl font-bold text-ink-black font-headline-md">Queue Clear!</h2>
|
| 223 |
+
<p className="text-xs font-sans text-trash-gray mt-2 leading-relaxed font-semibold">
|
| 224 |
+
Fantastic work. All active Leitner cards are up to date. Come back tomorrow for new spaced-repetition reviews!
|
| 225 |
+
</p>
|
| 226 |
+
</div>
|
| 227 |
+
{overrideProblemId && (
|
| 228 |
+
<button
|
| 229 |
+
onClick={onClearOverride}
|
| 230 |
+
className="bg-white border-[3px] border-ink-black text-ink-black font-bold px-4 py-2 text-xs uppercase tracking-wide hover:bg-slate-100 active:translate-y-0.5 shadow-[2px_2px_0px_0px_rgba(30,41,59,1)] active:shadow-none transition-all cursor-pointer inline-block"
|
| 231 |
+
>
|
| 232 |
+
Return to Daily Queue
|
| 233 |
+
</button>
|
| 234 |
+
)}
|
| 235 |
+
</div>
|
| 236 |
+
</div>
|
| 237 |
+
);
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
const activeReview = reviews[0];
|
| 241 |
+
const problem = activeReview.problems;
|
| 242 |
+
|
| 243 |
+
return (
|
| 244 |
+
<div className="w-full h-full bg-[#fdf8e1] overflow-y-auto custom-scrollbar p-6 flex flex-col">
|
| 245 |
+
<div className="flex-1 flex flex-col">
|
| 246 |
+
{/* Custom Override Header Banner */}
|
| 247 |
+
{overrideProblemId && (
|
| 248 |
+
<div className="p-3 mb-5 border-[3px] border-ink-black bg-yellow-100 flex flex-col sm:flex-row gap-3 justify-between items-center text-xs font-arcade text-ink-black shadow-[3px_3px_0px_0px_rgba(30,41,59,1)]">
|
| 249 |
+
<span className="flex items-center gap-2 font-bold">
|
| 250 |
+
<AlertTriangle className="w-4 h-4 text-amber-600 shrink-0" />
|
| 251 |
+
OVERRIDE: SELECTED MAP PROBLEM
|
| 252 |
+
</span>
|
| 253 |
+
<button
|
| 254 |
+
onClick={onClearOverride}
|
| 255 |
+
className="bg-white hover:bg-slate-100 text-ink-black font-bold px-3 py-1.5 border-[3px] border-ink-black shadow-[2px_2px_0px_0px_rgba(30,41,59,1)] active:translate-y-0.5 active:shadow-none transition-all text-[8px] uppercase tracking-wide cursor-pointer shrink-0"
|
| 256 |
+
>
|
| 257 |
+
Exit Override Mode
|
| 258 |
+
</button>
|
| 259 |
+
</div>
|
| 260 |
+
)}
|
| 261 |
+
|
| 262 |
+
{/* Problem Stats Header Card */}
|
| 263 |
+
<div className="mb-6 bg-paper-white border-[3px] border-ink-black p-5 relative overflow-hidden gadget-shadow shadow-[inset_2px_2px_0px_rgba(255,255,255,0.8)]">
|
| 264 |
+
<div className="absolute top-0 left-0 right-0 h-[5px] bg-gradient-to-r from-primary via-[#ffcc00] to-highlight-pink" />
|
| 265 |
+
|
| 266 |
+
<div className="flex justify-between items-start gap-4">
|
| 267 |
+
<div>
|
| 268 |
+
<div className="flex flex-wrap items-center gap-2 font-arcade text-[8px] tracking-wide text-trash-gray font-bold">
|
| 269 |
+
<span className="px-1.5 py-0.5 bg-white border-2 border-ink-black">BOX {activeReview.box_level}</span>
|
| 270 |
+
<span>•</span>
|
| 271 |
+
<span>CORRECT: {activeReview.times_correct}/{activeReview.total_attempts}</span>
|
| 272 |
+
<span>•</span>
|
| 273 |
+
<span>PATTERN: {problem.pattern}</span>
|
| 274 |
+
</div>
|
| 275 |
+
<h2 className="text-xl md:text-2xl font-extrabold text-ink-black tracking-tight mt-2 font-headline-md leading-tight">{problem.name}</h2>
|
| 276 |
+
</div>
|
| 277 |
+
|
| 278 |
+
<span className={`text-[10px] font-arcade font-bold px-3 py-1 border-[3px] border-ink-black shadow-[3px_3px_0px_0px_rgba(30,41,59,1)] shrink-0 ${
|
| 279 |
+
problem.difficulty === 'Easy' ? 'bg-green-500 text-white' :
|
| 280 |
+
problem.difficulty === 'Medium' ? 'bg-orange-500 text-white' :
|
| 281 |
+
'bg-red-500 text-white'
|
| 282 |
+
}`}>
|
| 283 |
+
{problem.difficulty.toUpperCase()}
|
| 284 |
+
</span>
|
| 285 |
+
</div>
|
| 286 |
+
|
| 287 |
+
{problem.description && (
|
| 288 |
+
<div className="mt-4 p-4 bg-slate-50 border-[3px] border-ink-black font-sans text-xs text-slate-800 leading-relaxed max-h-48 overflow-y-auto custom-scrollbar">
|
| 289 |
+
<div className="text-[9px] font-arcade text-slate-400 uppercase mb-2 font-bold">Problem Description</div>
|
| 290 |
+
<div className="font-semibold space-y-1">
|
| 291 |
+
{problem.description.split('\n').map((line: string, i: number) => (
|
| 292 |
+
<p key={i} className="m-0 leading-relaxed whitespace-pre-wrap">{line}</p>
|
| 293 |
+
))}
|
| 294 |
+
</div>
|
| 295 |
+
</div>
|
| 296 |
+
)}
|
| 297 |
+
|
| 298 |
+
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-5 border-t-[3px] border-dashed border-ink-black pt-4 text-xs font-bold text-ink-black">
|
| 299 |
+
<div>
|
| 300 |
+
<span className="text-[8px] font-arcade text-trash-gray block uppercase tracking-wider">Optimal Time</span>
|
| 301 |
+
<code className="bg-[#1E293B] text-[#4ADE80] font-code-mono px-2 py-0.5 border border-ink-black mt-1 inline-block text-xs">{problem.optimal_time}</code>
|
| 302 |
+
</div>
|
| 303 |
+
<div>
|
| 304 |
+
<span className="text-[8px] font-arcade text-trash-gray block uppercase tracking-wider">Optimal Space</span>
|
| 305 |
+
<code className="bg-[#1E293B] text-[#4ADE80] font-code-mono px-2 py-0.5 border border-ink-black mt-1 inline-block text-xs">{problem.optimal_space}</code>
|
| 306 |
+
</div>
|
| 307 |
+
<div>
|
| 308 |
+
<span className="text-[8px] font-arcade text-trash-gray block uppercase tracking-wider">Doc Link</span>
|
| 309 |
+
<a
|
| 310 |
+
href={problem.google_doc_url}
|
| 311 |
+
target="_blank"
|
| 312 |
+
rel="noreferrer"
|
| 313 |
+
className="flex items-center gap-1.5 text-primary hover:underline mt-1 inline-flex font-headline-md font-bold"
|
| 314 |
+
>
|
| 315 |
+
Google Doc
|
| 316 |
+
<ExternalLink className="w-3.5 h-3.5" />
|
| 317 |
+
</a>
|
| 318 |
+
</div>
|
| 319 |
+
<div>
|
| 320 |
+
<span className="text-[8px] font-arcade text-trash-gray block uppercase tracking-wider">Next Due</span>
|
| 321 |
+
<span className="text-xs text-ink-black block mt-2 font-code-mono">
|
| 322 |
+
{new Date(activeReview.next_review).toLocaleDateString()}
|
| 323 |
+
</span>
|
| 324 |
+
</div>
|
| 325 |
+
</div>
|
| 326 |
+
</div>
|
| 327 |
+
|
| 328 |
+
{/* Reference Code Expander (Chunky details element from code.html style) */}
|
| 329 |
+
<details className="mb-6 group border-[3px] border-ink-black bg-white shadow-[4px_4px_0px_0px_rgba(30,41,59,1)]">
|
| 330 |
+
<summary
|
| 331 |
+
onClick={() => setShowRefCode(!showRefCode)}
|
| 332 |
+
className="font-arcade text-[10px] p-3 cursor-pointer flex justify-between items-center outline-none hover:bg-secondary-container transition-colors select-none font-bold"
|
| 333 |
+
>
|
| 334 |
+
<span className="flex items-center gap-2">
|
| 335 |
+
<Code className="w-4 h-4 text-primary" />
|
| 336 |
+
{showRefCode ? 'HIDE REFERENCE CODE' : 'SHOW REFERENCE CODE'}
|
| 337 |
+
</span>
|
| 338 |
+
<ChevronDown className="w-4 h-4 group-open:rotate-180 transition-transform" />
|
| 339 |
+
</summary>
|
| 340 |
+
<div className="p-4 border-t-[3px] border-ink-black bg-[#1E293B] text-paper-white font-code-mono text-xs shadow-[inset_4px_4px_0px_rgba(0,0,0,0.5)] overflow-x-auto">
|
| 341 |
+
<pre className="font-mono text-xs leading-relaxed select-all text-[#4ADE80]">
|
| 342 |
+
<code>{problem.reference_code || '# No reference code found for this problem'}</code>
|
| 343 |
+
</pre>
|
| 344 |
+
|
| 345 |
+
{problem.reference_code && (
|
| 346 |
+
<div className="pt-4 mt-4 border-t border-slate-700 flex flex-col gap-4">
|
| 347 |
+
<button
|
| 348 |
+
onClick={handleExplainCode}
|
| 349 |
+
disabled={explainLoading}
|
| 350 |
+
className="w-fit bg-white hover:bg-slate-100 border-[3px] border-ink-black text-ink-black font-bold px-4 py-2 text-xs flex items-center gap-2 transition-all cursor-pointer disabled:opacity-50 shadow-[2px_2px_0px_0px_rgba(30,41,59,1)] active:translate-y-0.5 active:shadow-none"
|
| 351 |
+
>
|
| 352 |
+
{explainLoading ? (
|
| 353 |
+
<>
|
| 354 |
+
<RefreshCw className="w-3.5 h-3.5 animate-spin text-primary" />
|
| 355 |
+
Generating Explanation...
|
| 356 |
+
</>
|
| 357 |
+
) : (
|
| 358 |
+
<>
|
| 359 |
+
<span>💡 Explain Reference Code</span>
|
| 360 |
+
</>
|
| 361 |
+
)}
|
| 362 |
+
</button>
|
| 363 |
+
|
| 364 |
+
{/* Explain Error */}
|
| 365 |
+
{explainError && (
|
| 366 |
+
<div className="p-3 border-2 border-red-500 bg-red-100 text-error text-xs font-semibold">
|
| 367 |
+
{explainError}
|
| 368 |
+
</div>
|
| 369 |
+
)}
|
| 370 |
+
|
| 371 |
+
{/* Explanation display */}
|
| 372 |
+
{explanationText && (
|
| 373 |
+
<div className="bg-paper-white border-[3px] border-ink-black p-4 text-ink-black max-h-80 overflow-y-auto space-y-3 custom-scrollbar shadow-[inset_2px_2px_0px_rgba(0,0,0,0.05)]">
|
| 374 |
+
<h4 className="text-[10px] font-arcade font-bold text-primary mb-1 flex items-center gap-1.5 uppercase">
|
| 375 |
+
<Sparkles className="w-3.5 h-3.5" />
|
| 376 |
+
Code Breakdown
|
| 377 |
+
</h4>
|
| 378 |
+
<div className="border-t-2 border-dashed border-ink-black pt-2 font-sans font-semibold">
|
| 379 |
+
{parseMarkdown(explanationText)}
|
| 380 |
+
</div>
|
| 381 |
+
</div>
|
| 382 |
+
)}
|
| 383 |
+
</div>
|
| 384 |
+
)}
|
| 385 |
+
</div>
|
| 386 |
+
</details>
|
| 387 |
+
|
| 388 |
+
{/* Interactive Tabs */}
|
| 389 |
+
<div className="mb-6 flex-1 flex flex-col">
|
| 390 |
+
<div className="flex gap-2 mb-4 font-arcade text-[10px]">
|
| 391 |
+
<button
|
| 392 |
+
onClick={() => setActiveTab('ai')}
|
| 393 |
+
className={`px-4 py-2 border-[3px] border-ink-black cursor-pointer outline-none transition-all shadow-[4px_4px_0px_0px_rgba(30,41,59,1)] hover:bg-slate-100 ${
|
| 394 |
+
activeTab === 'ai'
|
| 395 |
+
? 'bg-[#c0e8ff] translate-y-1 shadow-[2px_2px_0px_0px_rgba(30,41,59,1)] font-bold'
|
| 396 |
+
: 'bg-white text-trash-gray'
|
| 397 |
+
}`}
|
| 398 |
+
>
|
| 399 |
+
AI GEMINI EVALUATION
|
| 400 |
+
</button>
|
| 401 |
+
<button
|
| 402 |
+
onClick={() => setActiveTab('offline')}
|
| 403 |
+
className={`px-4 py-2 border-[3px] border-ink-black cursor-pointer outline-none transition-all shadow-[4px_4px_0px_0px_rgba(30,41,59,1)] hover:bg-slate-100 ${
|
| 404 |
+
activeTab === 'offline'
|
| 405 |
+
? 'bg-[#c0e8ff] translate-y-1 shadow-[2px_2px_0px_0px_rgba(30,41,59,1)] font-bold'
|
| 406 |
+
: 'bg-white text-trash-gray'
|
| 407 |
+
}`}
|
| 408 |
+
>
|
| 409 |
+
OFFLINE SELF-GRADE
|
| 410 |
+
</button>
|
| 411 |
+
</div>
|
| 412 |
+
|
| 413 |
+
{/* Tab Content Box */}
|
| 414 |
+
<div className="border-[3px] border-ink-black bg-white p-5 shadow-[4px_4px_0px_0px_rgba(30,41,59,1)] flex-1 flex flex-col">
|
| 415 |
+
{activeTab === 'ai' ? (
|
| 416 |
+
<div className="flex-1 flex flex-col space-y-4">
|
| 417 |
+
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2">
|
| 418 |
+
<label className="block text-[9px] font-arcade font-bold text-trash-gray">
|
| 419 |
+
PASTE YOUR SOLUTION CODE FROM MEMORY:
|
| 420 |
+
</label>
|
| 421 |
+
|
| 422 |
+
<label className="inline-flex items-center gap-2 cursor-pointer select-none">
|
| 423 |
+
<input
|
| 424 |
+
type="checkbox"
|
| 425 |
+
checked={isAutocompleteEnabled}
|
| 426 |
+
onChange={(e) => {
|
| 427 |
+
setIsAutocompleteEnabled(e.target.checked);
|
| 428 |
+
localStorage.setItem('isAutocompleteEnabled', String(e.target.checked));
|
| 429 |
+
}}
|
| 430 |
+
className="sr-only peer"
|
| 431 |
+
/>
|
| 432 |
+
<div className="relative w-8 h-4 bg-slate-300 border border-ink-black rounded-full peer peer-checked:bg-grass-green transition-all after:content-[''] after:absolute after:top-[1px] after:left-[2px] after:bg-white after:border after:border-ink-black after:rounded-full after:h-2.5 after:w-2.5 after:transition-all peer-checked:after:translate-x-3.5"></div>
|
| 433 |
+
<span className="text-[8px] font-arcade font-bold text-trash-gray peer-checked:text-ink-black transition-colors">
|
| 434 |
+
🤖 AUTOCOMPLETE
|
| 435 |
+
</span>
|
| 436 |
+
</label>
|
| 437 |
+
</div>
|
| 438 |
+
|
| 439 |
+
<div className="flex-1 min-h-[220px] flex flex-col">
|
| 440 |
+
<CodeMirror
|
| 441 |
+
value={code}
|
| 442 |
+
onChange={(value) => setCode(value)}
|
| 443 |
+
placeholder="def solve(nums): # Write code here..."
|
| 444 |
+
height="100%"
|
| 445 |
+
theme="dark"
|
| 446 |
+
extensions={[python()]}
|
| 447 |
+
basicSetup={{
|
| 448 |
+
autocompletion: isAutocompleteEnabled,
|
| 449 |
+
lineNumbers: true,
|
| 450 |
+
foldGutter: true,
|
| 451 |
+
dropCursor: true,
|
| 452 |
+
allowMultipleSelections: true,
|
| 453 |
+
indentOnInput: true,
|
| 454 |
+
bracketMatching: true,
|
| 455 |
+
closeBrackets: true,
|
| 456 |
+
rectangularSelection: true,
|
| 457 |
+
crosshairCursor: true,
|
| 458 |
+
highlightActiveLine: true,
|
| 459 |
+
highlightSelectionMatches: true,
|
| 460 |
+
closeBracketsKeymap: true,
|
| 461 |
+
defaultKeymap: true,
|
| 462 |
+
searchKeymap: true,
|
| 463 |
+
historyKeymap: true,
|
| 464 |
+
foldKeymap: true,
|
| 465 |
+
completionKeymap: true,
|
| 466 |
+
lintKeymap: true,
|
| 467 |
+
}}
|
| 468 |
+
className="w-full h-full flex-1 min-h-[200px]"
|
| 469 |
+
/>
|
| 470 |
+
</div>
|
| 471 |
+
|
| 472 |
+
<div className="flex flex-col sm:flex-row justify-between items-center gap-4 pt-2">
|
| 473 |
+
<span className="text-[10px] text-trash-gray italic max-w-sm font-semibold leading-normal">
|
| 474 |
+
Gemini will inspect your algorithm for logical correctness, time/space limits, and edge cases.
|
| 475 |
+
</span>
|
| 476 |
+
|
| 477 |
+
<button
|
| 478 |
+
onClick={handleAiEvaluation}
|
| 479 |
+
disabled={aiLoading || !code.trim()}
|
| 480 |
+
className="w-full sm:w-auto bg-[#a855f7] hover:bg-[#9333ea] text-white font-arcade text-[10px] py-3 px-6 border-[3px] border-ink-black shadow-[4px_4px_0px_0px_rgba(30,41,59,1)] active:translate-y-1 active:translate-x-1 active:shadow-none transition-all cursor-pointer font-bold select-none shrink-0"
|
| 481 |
+
>
|
| 482 |
+
{aiLoading ? (
|
| 483 |
+
<span className="flex items-center gap-2 justify-center">
|
| 484 |
+
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
| 485 |
+
RUNNING EVAL...
|
| 486 |
+
</span>
|
| 487 |
+
) : (
|
| 488 |
+
'SUBMIT FOR AI REVIEW'
|
| 489 |
+
)}
|
| 490 |
+
</button>
|
| 491 |
+
</div>
|
| 492 |
+
|
| 493 |
+
{/* AI Review Loader Panel */}
|
| 494 |
+
{aiLoading && (
|
| 495 |
+
<div className="p-6 border-[3px] border-ink-black bg-[#fdf8e1] gadget-shadow flex flex-col items-center justify-center gap-3 animate-pulse">
|
| 496 |
+
<RefreshCw className="w-7 h-7 text-primary animate-spin" />
|
| 497 |
+
<div className="text-center">
|
| 498 |
+
<h4 className="text-xs font-bold text-ink-black font-arcade">AI COMPILING SUBMISSION</h4>
|
| 499 |
+
<p className="text-[9px] text-trash-gray mt-1 font-bold">Verifying constraints, checking edge cases, evaluating big-O complexity...</p>
|
| 500 |
+
</div>
|
| 501 |
+
</div>
|
| 502 |
+
)}
|
| 503 |
+
|
| 504 |
+
{/* AI Error display */}
|
| 505 |
+
{aiError && (
|
| 506 |
+
<div className="p-4 border-[3px] border-red-500 bg-red-100 text-error text-xs font-semibold shadow-[2px_2px_0px_0px_rgba(30,41,59,1)]">
|
| 507 |
+
{aiError}
|
| 508 |
+
</div>
|
| 509 |
+
)}
|
| 510 |
+
|
| 511 |
+
{/* AI Result details */}
|
| 512 |
+
{aiResult && (
|
| 513 |
+
<div className="border-[3px] border-ink-black bg-[#fdf8e1] p-5 gadget-shadow space-y-4">
|
| 514 |
+
<div className="flex items-center gap-3 pb-3 border-b-2 border-dashed border-ink-black">
|
| 515 |
+
<div className={`w-10 h-10 border-[3px] border-ink-black flex items-center justify-center gadget-shadow shrink-0 ${
|
| 516 |
+
aiResult.is_correct
|
| 517 |
+
? 'bg-grass-green text-ink-black'
|
| 518 |
+
: 'bg-highlight-pink text-white'
|
| 519 |
+
}`}>
|
| 520 |
+
<Award className="w-5 h-5" />
|
| 521 |
+
</div>
|
| 522 |
+
<div>
|
| 523 |
+
<h3 className="text-xs font-bold font-arcade text-ink-black">VERDICT RESULT</h3>
|
| 524 |
+
<p className="text-[9px] text-trash-gray font-bold uppercase mt-1">
|
| 525 |
+
{aiResult.is_correct ? 'Logical verification passed successfully.' : 'Logical verification failed.'}
|
| 526 |
+
</p>
|
| 527 |
+
</div>
|
| 528 |
+
</div>
|
| 529 |
+
|
| 530 |
+
<div className="grid grid-cols-2 gap-4">
|
| 531 |
+
<div className="p-3 bg-white border-[3px] border-ink-black shadow-[2px_2px_0px_0px_rgba(30,41,59,1)]">
|
| 532 |
+
<span className="text-[8px] font-arcade text-trash-gray block uppercase">Detected Time</span>
|
| 533 |
+
<p className="font-bold text-xs text-ink-black mt-1 font-code-mono">{aiResult.detected_complexity?.time || 'N/A'}</p>
|
| 534 |
+
</div>
|
| 535 |
+
<div className="p-3 bg-white border-[3px] border-ink-black shadow-[2px_2px_0px_0px_rgba(30,41,59,1)]">
|
| 536 |
+
<span className="text-[8px] font-arcade text-trash-gray block uppercase">Detected Space</span>
|
| 537 |
+
<p className="font-bold text-xs text-ink-black mt-1 font-code-mono">{aiResult.detected_complexity?.space || 'N/A'}</p>
|
| 538 |
+
</div>
|
| 539 |
+
</div>
|
| 540 |
+
|
| 541 |
+
{aiResult.bugs && aiResult.bugs.length > 0 && (
|
| 542 |
+
<div className="space-y-1.5">
|
| 543 |
+
<span className="text-[8px] font-arcade text-trash-gray uppercase block font-bold">Traceback / Edge Case Failures:</span>
|
| 544 |
+
<ul className="list-disc list-inside text-xs text-ink-black space-y-1 pl-1 font-semibold">
|
| 545 |
+
{aiResult.bugs.map((bug: string, idx: number) => (
|
| 546 |
+
<li key={idx} className="leading-relaxed">{bug}</li>
|
| 547 |
+
))}
|
| 548 |
+
</ul>
|
| 549 |
+
</div>
|
| 550 |
+
)}
|
| 551 |
+
|
| 552 |
+
{aiResult.key_suggestion && (
|
| 553 |
+
<div className="p-3 bg-yellow-50 border-[3px] border-ink-black rounded-lg space-y-1">
|
| 554 |
+
<span className="text-[8px] font-arcade text-[#d97706] uppercase font-bold block">Refactoring Tip</span>
|
| 555 |
+
<p className="text-xs text-ink-black font-semibold leading-relaxed m-0">{aiResult.key_suggestion}</p>
|
| 556 |
+
</div>
|
| 557 |
+
)}
|
| 558 |
+
|
| 559 |
+
<div className="pt-2 border-t-2 border-dashed border-ink-black">
|
| 560 |
+
<p className="text-xs text-ink-black font-bold">
|
| 561 |
+
🤖 AI SUGGESTION: {aiResult.is_correct ? (
|
| 562 |
+
<span className="text-emerald-600 font-bold">Click "Smashed It" to increment box level!</span>
|
| 563 |
+
) : (
|
| 564 |
+
<span className="text-red-500 font-bold">Click "Forgot" to reset back to Box 1.</span>
|
| 565 |
+
)}
|
| 566 |
+
</p>
|
| 567 |
+
</div>
|
| 568 |
+
</div>
|
| 569 |
+
)}
|
| 570 |
+
</div>
|
| 571 |
+
) : (
|
| 572 |
+
<div className="flex-1 font-sans text-xs text-ink-black leading-relaxed space-y-4 font-semibold">
|
| 573 |
+
<h3 className="font-bold text-sm font-arcade uppercase m-0">Offline grading instructions:</h3>
|
| 574 |
+
<ol className="list-decimal list-inside space-y-2.5">
|
| 575 |
+
<li>Expand the <span className="font-bold text-primary">"Show Reference Code"</span> block above.</li>
|
| 576 |
+
<li>Compare your logic or mental draft with the reference algorithm.</li>
|
| 577 |
+
<li>Verify if you correctly recalled the patterns, Big-O limit, and transitions.</li>
|
| 578 |
+
<li>Score yourself using the buttons below:</li>
|
| 579 |
+
</ol>
|
| 580 |
+
<div className="border-t-2 border-dashed border-ink-black pt-4 font-arcade text-[8px] leading-loose text-trash-gray space-y-2 font-bold uppercase">
|
| 581 |
+
<div>🔴 <span className="text-red-500">Forgot</span>: resets box back to Box 1.</div>
|
| 582 |
+
<div>🟡 <span className="text-amber-500">Mixed</span>: keeps the current Box level.</div>
|
| 583 |
+
<div>🟢 <span className="text-emerald-500">Smashed It</span>: increments box level by 1.</div>
|
| 584 |
+
</div>
|
| 585 |
+
</div>
|
| 586 |
+
)}
|
| 587 |
+
</div>
|
| 588 |
+
</div>
|
| 589 |
+
|
| 590 |
+
{/* SRS Completion Logging Buttons */}
|
| 591 |
+
<div className="flex gap-4 mt-auto shrink-0 pt-4 border-t-[3px] border-ink-black border-dashed">
|
| 592 |
+
<button
|
| 593 |
+
onClick={() => handleGrade('Incorrect')}
|
| 594 |
+
disabled={submitting}
|
| 595 |
+
className="flex-1 bg-[#ff1a1a] border-[4px] border-ink-black rounded-xl py-4 font-window-title text-base md:text-lg text-white font-bold arcade-btn cursor-pointer disabled:opacity-50"
|
| 596 |
+
>
|
| 597 |
+
Forgot
|
| 598 |
+
</button>
|
| 599 |
+
<button
|
| 600 |
+
onClick={() => handleGrade('Mixed')}
|
| 601 |
+
disabled={submitting}
|
| 602 |
+
className="flex-1 bg-[#ffcc00] border-[4px] border-ink-black rounded-xl py-4 font-window-title text-base md:text-lg text-ink-black font-bold arcade-btn cursor-pointer disabled:opacity-50"
|
| 603 |
+
>
|
| 604 |
+
Mixed
|
| 605 |
+
</button>
|
| 606 |
+
<button
|
| 607 |
+
onClick={() => handleGrade('Correct')}
|
| 608 |
+
disabled={submitting}
|
| 609 |
+
className="flex-1 bg-[#39ff14] border-[4px] border-ink-black rounded-xl py-4 font-window-title text-base md:text-lg text-ink-black font-bold arcade-btn cursor-pointer disabled:opacity-50"
|
| 610 |
+
>
|
| 611 |
+
Smashed It
|
| 612 |
+
</button>
|
| 613 |
+
</div>
|
| 614 |
+
</div>
|
| 615 |
+
</div>
|
| 616 |
+
);
|
| 617 |
+
}
|
| 618 |
+
|
| 619 |
+
// Simple Markdown Parser Helpers
|
| 620 |
+
function renderInlineMarkdown(text: string) {
|
| 621 |
+
const regex = /(\*\*.*?\*\*|`.*?`)/g;
|
| 622 |
+
const tokens = text.split(regex);
|
| 623 |
+
|
| 624 |
+
return tokens.map((token, index) => {
|
| 625 |
+
if (token.startsWith('**') && token.endsWith('**')) {
|
| 626 |
+
return (
|
| 627 |
+
<strong key={index} className="text-ink-black font-bold">
|
| 628 |
+
{token.slice(2, -2)}
|
| 629 |
+
</strong>
|
| 630 |
+
);
|
| 631 |
+
}
|
| 632 |
+
if (token.startsWith('`') && token.endsWith('`')) {
|
| 633 |
+
return (
|
| 634 |
+
<code key={index} className="bg-[#1E293B] text-[#4ADE80] px-1.5 py-0.5 rounded text-[11px] font-mono border border-ink-black">
|
| 635 |
+
{token.slice(1, -1)}
|
| 636 |
+
</code>
|
| 637 |
+
);
|
| 638 |
+
}
|
| 639 |
+
return token;
|
| 640 |
+
});
|
| 641 |
+
}
|
| 642 |
+
|
| 643 |
+
function parseMarkdown(text: string) {
|
| 644 |
+
const parts = text.split(/(```[\s\S]*?```)/g);
|
| 645 |
+
return parts.map((part, index) => {
|
| 646 |
+
if (part.startsWith('```')) {
|
| 647 |
+
const lines = part.split('\n');
|
| 648 |
+
const code = lines.slice(1, -1).join('\n');
|
| 649 |
+
return (
|
| 650 |
+
<pre key={index} className="bg-[#1E293B] p-3 rounded-lg border border-ink-black overflow-x-auto font-mono text-[11px] text-[#4ADE80] my-2">
|
| 651 |
+
<code>{code}</code>
|
| 652 |
+
</pre>
|
| 653 |
+
);
|
| 654 |
+
}
|
| 655 |
+
|
| 656 |
+
const lines = part.split('\n');
|
| 657 |
+
return lines.map((line, lineIndex) => {
|
| 658 |
+
const key = `${index}-${lineIndex}`;
|
| 659 |
+
if (line.startsWith('### ')) {
|
| 660 |
+
return <h3 key={key} className="text-xs font-bold text-ink-black mt-3 mb-1.5 uppercase font-arcade">{line.substring(4)}</h3>;
|
| 661 |
+
}
|
| 662 |
+
if (line.startsWith('## ')) {
|
| 663 |
+
return <h2 key={key} className="text-sm font-bold text-ink-black mt-4 mb-2 font-headline-md">{line.substring(3)}</h2>;
|
| 664 |
+
}
|
| 665 |
+
if (line.startsWith('# ')) {
|
| 666 |
+
return <h1 key={key} className="text-base font-extrabold text-ink-black mt-5 mb-2 font-headline-md">{line.substring(2)}</h1>;
|
| 667 |
+
}
|
| 668 |
+
if (line.trim().startsWith('- ') || line.trim().startsWith('* ')) {
|
| 669 |
+
const content = line.trim().substring(2);
|
| 670 |
+
return (
|
| 671 |
+
<div key={key} className="flex items-start gap-1.5 pl-2 my-1">
|
| 672 |
+
<span className="text-primary mt-1">•</span>
|
| 673 |
+
<span className="text-ink-black text-xs leading-relaxed">{renderInlineMarkdown(content)}</span>
|
| 674 |
+
</div>
|
| 675 |
+
);
|
| 676 |
+
}
|
| 677 |
+
if (line.trim() === '') {
|
| 678 |
+
return <div key={key} className="h-2" />;
|
| 679 |
+
}
|
| 680 |
+
return <p key={key} className="text-ink-black text-xs leading-relaxed my-1">{renderInlineMarkdown(line)}</p>;
|
| 681 |
+
});
|
| 682 |
+
});
|
| 683 |
+
}
|
algospaced-ui/src/components/Sidebar.tsx
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from 'react';
|
| 2 |
+
import { supabase } from '../supabaseClient';
|
| 3 |
+
import { BookOpen, Map, Flame, LogOut, RefreshCw, Trophy, User } from 'lucide-react';
|
| 4 |
+
|
| 5 |
+
interface SidebarProps {
|
| 6 |
+
activeTab: 'queue' | 'journey';
|
| 7 |
+
setActiveTab: (tab: 'queue' | 'journey') => void;
|
| 8 |
+
user: any;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export default function Sidebar({ activeTab, setActiveTab, user }: SidebarProps) {
|
| 12 |
+
const [streak, setStreak] = useState<any>(null);
|
| 13 |
+
const [syncing, setSyncing] = useState(false);
|
| 14 |
+
const [syncResult, setSyncResult] = useState<string | null>(null);
|
| 15 |
+
|
| 16 |
+
const fetchStreak = async () => {
|
| 17 |
+
try {
|
| 18 |
+
const { data: { session } } = await supabase.auth.getSession();
|
| 19 |
+
if (!session) return;
|
| 20 |
+
|
| 21 |
+
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/streak`, {
|
| 22 |
+
headers: {
|
| 23 |
+
'Authorization': `Bearer ${session.access_token}`
|
| 24 |
+
}
|
| 25 |
+
});
|
| 26 |
+
if (res.ok) {
|
| 27 |
+
const data = await res.json();
|
| 28 |
+
setStreak(data);
|
| 29 |
+
}
|
| 30 |
+
} catch (err) {
|
| 31 |
+
console.error('Error fetching streak:', err);
|
| 32 |
+
}
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
useEffect(() => {
|
| 36 |
+
fetchStreak();
|
| 37 |
+
}, [user]);
|
| 38 |
+
|
| 39 |
+
const handleSync = async () => {
|
| 40 |
+
setSyncing(true);
|
| 41 |
+
setSyncResult(null);
|
| 42 |
+
try {
|
| 43 |
+
const { data: { session } } = await supabase.auth.getSession();
|
| 44 |
+
if (!session) return;
|
| 45 |
+
|
| 46 |
+
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/sync`, {
|
| 47 |
+
method: 'POST',
|
| 48 |
+
headers: {
|
| 49 |
+
'Authorization': `Bearer ${session.access_token}`
|
| 50 |
+
}
|
| 51 |
+
});
|
| 52 |
+
if (res.ok) {
|
| 53 |
+
const data = await res.json();
|
| 54 |
+
setSyncResult(data.message || 'Sync complete!');
|
| 55 |
+
// Refresh streak and state
|
| 56 |
+
fetchStreak();
|
| 57 |
+
} else {
|
| 58 |
+
setSyncResult('Sync failed. Check backend logs.');
|
| 59 |
+
}
|
| 60 |
+
} catch (err) {
|
| 61 |
+
setSyncResult('Sync failed. Server unreachable.');
|
| 62 |
+
} finally {
|
| 63 |
+
setSyncing(false);
|
| 64 |
+
}
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
const handleSignOut = async () => {
|
| 68 |
+
await supabase.auth.signOut();
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
return (
|
| 72 |
+
<aside className="w-80 h-screen bg-bg-sidebar border-r border-slate-900 flex flex-col justify-between p-6 shrink-0 z-10 relative">
|
| 73 |
+
<div className="space-y-8">
|
| 74 |
+
{/* Brand */}
|
| 75 |
+
<div className="flex items-center gap-3">
|
| 76 |
+
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-purple to-accent-pink flex items-center justify-center shadow-[0_0_15px_rgba(157,78,221,0.3)]">
|
| 77 |
+
<Trophy className="w-5 h-5 text-white" />
|
| 78 |
+
</div>
|
| 79 |
+
<div>
|
| 80 |
+
<h1 className="text-xl font-bold tracking-tight text-white m-0">AlgoSpaced</h1>
|
| 81 |
+
<span className="text-xs text-slate-500">DSA Repetition</span>
|
| 82 |
+
</div>
|
| 83 |
+
</div>
|
| 84 |
+
|
| 85 |
+
{/* Navigation */}
|
| 86 |
+
<nav className="space-y-2">
|
| 87 |
+
<button
|
| 88 |
+
onClick={() => setActiveTab('queue')}
|
| 89 |
+
className={`w-full flex items-center gap-3.5 px-4 py-3 rounded-xl text-sm font-semibold tracking-wide transition-all duration-200 cursor-pointer ${
|
| 90 |
+
activeTab === 'queue'
|
| 91 |
+
? 'bg-primary-purple text-white shadow-[0_0_15px_rgba(157,78,221,0.25)]'
|
| 92 |
+
: 'text-slate-400 hover:text-white hover:bg-slate-950'
|
| 93 |
+
}`}
|
| 94 |
+
>
|
| 95 |
+
<BookOpen className="w-5 h-5" />
|
| 96 |
+
Daily Review Queue
|
| 97 |
+
</button>
|
| 98 |
+
<button
|
| 99 |
+
onClick={() => setActiveTab('journey')}
|
| 100 |
+
className={`w-full flex items-center gap-3.5 px-4 py-3 rounded-xl text-sm font-semibold tracking-wide transition-all duration-200 cursor-pointer ${
|
| 101 |
+
activeTab === 'journey'
|
| 102 |
+
? 'bg-primary-purple text-white shadow-[0_0_15px_rgba(157,78,221,0.25)]'
|
| 103 |
+
: 'text-slate-400 hover:text-white hover:bg-slate-950'
|
| 104 |
+
}`}
|
| 105 |
+
>
|
| 106 |
+
<Map className="w-5 h-5" />
|
| 107 |
+
Journey Path
|
| 108 |
+
</button>
|
| 109 |
+
</nav>
|
| 110 |
+
|
| 111 |
+
{/* Streak Component */}
|
| 112 |
+
<div className="glass-panel rounded-2xl p-5 border border-slate-900">
|
| 113 |
+
<div className="flex items-center gap-3 mb-4">
|
| 114 |
+
<div className="p-2 rounded-lg bg-orange-500/10 text-orange-400">
|
| 115 |
+
<Flame className="w-5 h-5" />
|
| 116 |
+
</div>
|
| 117 |
+
<div>
|
| 118 |
+
<h3 className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Active Streak</h3>
|
| 119 |
+
<span className="text-2xl font-bold text-white tracking-tight">{streak?.current_streak ?? 0} days</span>
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
<div className="flex justify-between items-center text-xs text-slate-500 border-t border-slate-900/60 pt-3">
|
| 123 |
+
<span>Longest: {streak?.longest_streak ?? 0} days</span>
|
| 124 |
+
{streak?.last_active_date && (
|
| 125 |
+
<span>Last active: {new Date(streak.last_active_date).toLocaleDateString()}</span>
|
| 126 |
+
)}
|
| 127 |
+
</div>
|
| 128 |
+
</div>
|
| 129 |
+
|
| 130 |
+
{/* Sync Google Drive */}
|
| 131 |
+
<div className="space-y-2">
|
| 132 |
+
<button
|
| 133 |
+
onClick={handleSync}
|
| 134 |
+
disabled={syncing}
|
| 135 |
+
className="w-full bg-slate-950 hover:bg-slate-900 text-slate-300 font-semibold py-3 px-4 rounded-xl flex items-center justify-center gap-2 border border-slate-800 transition-all text-sm cursor-pointer disabled:opacity-50"
|
| 136 |
+
>
|
| 137 |
+
<RefreshCw className={`w-4 h-4 text-accent-cyan ${syncing ? 'animate-spin' : ''}`} />
|
| 138 |
+
{syncing ? 'Syncing...' : 'Sync Google Drive'}
|
| 139 |
+
</button>
|
| 140 |
+
{syncResult && (
|
| 141 |
+
<p className="text-[11px] text-center text-accent-cyan leading-normal max-h-16 overflow-y-auto px-2">
|
| 142 |
+
{syncResult}
|
| 143 |
+
</p>
|
| 144 |
+
)}
|
| 145 |
+
</div>
|
| 146 |
+
</div>
|
| 147 |
+
|
| 148 |
+
{/* User Session Info / Sign Out */}
|
| 149 |
+
<div className="border-t border-slate-900 pt-5 space-y-4">
|
| 150 |
+
<div className="flex items-center gap-3 px-2">
|
| 151 |
+
<div className="w-8 h-8 rounded-full bg-slate-900 border border-slate-800 flex items-center justify-center text-slate-400">
|
| 152 |
+
<User className="w-4 h-4" />
|
| 153 |
+
</div>
|
| 154 |
+
<div className="overflow-hidden">
|
| 155 |
+
<p className="text-xs font-semibold text-white truncate m-0">{user.email}</p>
|
| 156 |
+
<p className="text-[10px] text-slate-500 truncate m-0">Verified User</p>
|
| 157 |
+
</div>
|
| 158 |
+
</div>
|
| 159 |
+
<button
|
| 160 |
+
onClick={handleSignOut}
|
| 161 |
+
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-lg text-xs font-semibold text-rose-400 hover:text-rose-300 bg-rose-500/5 hover:bg-rose-500/10 transition-all cursor-pointer"
|
| 162 |
+
>
|
| 163 |
+
<LogOut className="w-4 h-4" />
|
| 164 |
+
Sign Out
|
| 165 |
+
</button>
|
| 166 |
+
</div>
|
| 167 |
+
</aside>
|
| 168 |
+
);
|
| 169 |
+
}
|
algospaced-ui/src/components/TerminalEmulator.tsx
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useRef, useEffect } from 'react';
|
| 2 |
+
import { supabase } from '../supabaseClient';
|
| 3 |
+
|
| 4 |
+
interface TerminalEmulatorProps {
|
| 5 |
+
onOpenWindow: (windowId: string) => void;
|
| 6 |
+
onClose: () => void;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
// Custom helper to format result rows as a clean ASCII text table
|
| 10 |
+
function formatAsciiTable(columns: string[], rows: any[]): string[] {
|
| 11 |
+
if (columns.length === 0 || rows.length === 0) {
|
| 12 |
+
return ['Empty result set.'];
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
// Calculate column widths
|
| 16 |
+
const widths: Record<string, number> = {};
|
| 17 |
+
columns.forEach(col => {
|
| 18 |
+
widths[col] = col.length;
|
| 19 |
+
});
|
| 20 |
+
|
| 21 |
+
rows.forEach(row => {
|
| 22 |
+
columns.forEach(col => {
|
| 23 |
+
const valStr = row[col] !== null && row[col] !== undefined ? String(row[col]) : 'NULL';
|
| 24 |
+
if (valStr.length > (widths[col] || 0)) {
|
| 25 |
+
widths[col] = valStr.length;
|
| 26 |
+
}
|
| 27 |
+
});
|
| 28 |
+
});
|
| 29 |
+
|
| 30 |
+
// Build grid components
|
| 31 |
+
const separator = '+' + columns.map(col => '-'.repeat(widths[col] + 2)).join('+') + '+';
|
| 32 |
+
const header = '|' + columns.map(col => ` ${col.padEnd(widths[col])} `).join('|') + '|';
|
| 33 |
+
|
| 34 |
+
const lines: string[] = [separator, header, separator];
|
| 35 |
+
|
| 36 |
+
rows.forEach(row => {
|
| 37 |
+
const rowLine = '|' + columns.map(col => {
|
| 38 |
+
const valStr = row[col] !== null && row[col] !== undefined ? String(row[col]) : 'NULL';
|
| 39 |
+
return ` ${valStr.padEnd(widths[col])} `;
|
| 40 |
+
}).join('|') + '|';
|
| 41 |
+
lines.push(rowLine);
|
| 42 |
+
});
|
| 43 |
+
|
| 44 |
+
lines.push(separator);
|
| 45 |
+
return lines;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
export default function TerminalEmulator({ onOpenWindow, onClose }: TerminalEmulatorProps) {
|
| 49 |
+
const [history, setHistory] = useState<string[]>([
|
| 50 |
+
'AlgoSpaced OS [Version 1.0.24]',
|
| 51 |
+
'(c) Retro Arcade Systems. All rights reserved.',
|
| 52 |
+
'',
|
| 53 |
+
'Type "help" for a list of available commands.',
|
| 54 |
+
''
|
| 55 |
+
]);
|
| 56 |
+
const [input, setInput] = useState('');
|
| 57 |
+
const containerRef = useRef<HTMLDivElement>(null);
|
| 58 |
+
|
| 59 |
+
useEffect(() => {
|
| 60 |
+
if (containerRef.current) {
|
| 61 |
+
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
| 62 |
+
}
|
| 63 |
+
}, [history]);
|
| 64 |
+
|
| 65 |
+
// Tokenize inputs while respecting single/double quoted strings
|
| 66 |
+
const tokenize = (cmd: string): { command: string; args: string[] } => {
|
| 67 |
+
const trimmed = cmd.trim();
|
| 68 |
+
const regex = /[^\s"']+|"([^"]*)"|'([^']*)'/g;
|
| 69 |
+
const args: string[] = [];
|
| 70 |
+
let match;
|
| 71 |
+
while ((match = regex.exec(trimmed)) !== null) {
|
| 72 |
+
args.push(match[1] || match[2] || match[0]);
|
| 73 |
+
}
|
| 74 |
+
return {
|
| 75 |
+
command: args[0]?.toLowerCase() || '',
|
| 76 |
+
args: args.slice(1)
|
| 77 |
+
};
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
+
const handleCommand = async (cmd: string) => {
|
| 81 |
+
const trimmed = cmd.trim();
|
| 82 |
+
if (trimmed === '') {
|
| 83 |
+
setHistory(prev => [...prev, '$']);
|
| 84 |
+
return;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
const { command, args } = tokenize(trimmed);
|
| 88 |
+
let output: string[] = [`$ ${trimmed}`];
|
| 89 |
+
|
| 90 |
+
switch (command) {
|
| 91 |
+
case 'help':
|
| 92 |
+
output.push(
|
| 93 |
+
'Available commands:',
|
| 94 |
+
' help - Display this documentation',
|
| 95 |
+
' streak - Retrieve your active Leitner combo streak metrics',
|
| 96 |
+
' sync - Run google drive file import sync',
|
| 97 |
+
' reviews - Fetch Leetcode numbers due for daily review',
|
| 98 |
+
' python play - Open Daily Python Challenge window',
|
| 99 |
+
' db init - Initialize SQL session and open playground window',
|
| 100 |
+
' db status - Check active SQL session objectives',
|
| 101 |
+
' db query [Q] - Execute query and format results inside CLI',
|
| 102 |
+
' open - Open window (usage: open queue | journey | system)',
|
| 103 |
+
' clear - Clear terminal buffer',
|
| 104 |
+
' exit - Close the terminal shell'
|
| 105 |
+
);
|
| 106 |
+
break;
|
| 107 |
+
case 'clear':
|
| 108 |
+
setHistory([]);
|
| 109 |
+
return;
|
| 110 |
+
case 'exit':
|
| 111 |
+
output.push('Closing terminal session...');
|
| 112 |
+
setTimeout(() => {
|
| 113 |
+
onClose();
|
| 114 |
+
}, 500);
|
| 115 |
+
break;
|
| 116 |
+
case 'open': {
|
| 117 |
+
const target = args[0]?.toLowerCase();
|
| 118 |
+
if (target === 'queue' || target === 'daily' || target === 'reviews') {
|
| 119 |
+
onOpenWindow('queue');
|
| 120 |
+
output.push('Launching Daily Queue window...');
|
| 121 |
+
} else if (target === 'journey' || target === 'path' || target === 'map') {
|
| 122 |
+
onOpenWindow('journey');
|
| 123 |
+
output.push('Launching Journey Path window...');
|
| 124 |
+
} else if (target === 'system' || target === 'computer' || target === 'sys') {
|
| 125 |
+
onOpenWindow('computer');
|
| 126 |
+
output.push('Launching System Information window...');
|
| 127 |
+
} else if (target === 'python' || target === 'challenge') {
|
| 128 |
+
onOpenWindow('python');
|
| 129 |
+
output.push('Launching Daily Python Challenge window...');
|
| 130 |
+
} else if (target === 'db' || target === 'mysql' || target === 'sql') {
|
| 131 |
+
onOpenWindow('mysql');
|
| 132 |
+
output.push('Launching MySQL Playground window...');
|
| 133 |
+
} else {
|
| 134 |
+
output.push('Usage: open [queue | journey | system | python | db]');
|
| 135 |
+
}
|
| 136 |
+
break;
|
| 137 |
+
}
|
| 138 |
+
case 'streak':
|
| 139 |
+
output.push('Connecting to streak.sys database...');
|
| 140 |
+
try {
|
| 141 |
+
const { data: { session } } = await supabase.auth.getSession();
|
| 142 |
+
if (!session) {
|
| 143 |
+
output.push('Error: Unauthenticated session');
|
| 144 |
+
break;
|
| 145 |
+
}
|
| 146 |
+
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/streak`, {
|
| 147 |
+
headers: { 'Authorization': `Bearer ${session.access_token}` }
|
| 148 |
+
});
|
| 149 |
+
if (res.ok) {
|
| 150 |
+
const data = await res.json();
|
| 151 |
+
output.push(
|
| 152 |
+
'Database Response:',
|
| 153 |
+
` Active Streak: ${data.current_streak} days`,
|
| 154 |
+
` Max Combo: ${data.longest_streak} days`,
|
| 155 |
+
` Last Activity: ${data.last_active_date ? new Date(data.last_active_date).toLocaleDateString() : 'N/A'}`
|
| 156 |
+
);
|
| 157 |
+
} else {
|
| 158 |
+
output.push('Error: Failed to query database endpoint');
|
| 159 |
+
}
|
| 160 |
+
} catch {
|
| 161 |
+
output.push('Error: Connection timed out');
|
| 162 |
+
}
|
| 163 |
+
break;
|
| 164 |
+
case 'sync':
|
| 165 |
+
output.push('Initializing Google Drive synchronization sync.exe...');
|
| 166 |
+
try {
|
| 167 |
+
const { data: { session } } = await supabase.auth.getSession();
|
| 168 |
+
if (!session) {
|
| 169 |
+
output.push('Error: Unauthenticated session');
|
| 170 |
+
break;
|
| 171 |
+
}
|
| 172 |
+
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/sync`, {
|
| 173 |
+
method: 'POST',
|
| 174 |
+
headers: { 'Authorization': `Bearer ${session.access_token}` }
|
| 175 |
+
});
|
| 176 |
+
if (res.ok) {
|
| 177 |
+
const data = await res.json();
|
| 178 |
+
output.push(`Sync Result: ${data.message}`);
|
| 179 |
+
} else {
|
| 180 |
+
output.push('Error: Sync command failed at backend host');
|
| 181 |
+
}
|
| 182 |
+
} catch {
|
| 183 |
+
output.push('Error: Cloud host unreachable');
|
| 184 |
+
}
|
| 185 |
+
break;
|
| 186 |
+
case 'reviews':
|
| 187 |
+
output.push('Querying leitner_cards partition...');
|
| 188 |
+
try {
|
| 189 |
+
const { data: { session } } = await supabase.auth.getSession();
|
| 190 |
+
if (!session) {
|
| 191 |
+
output.push('Error: Unauthenticated session');
|
| 192 |
+
break;
|
| 193 |
+
}
|
| 194 |
+
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/reviews/due`, {
|
| 195 |
+
headers: { 'Authorization': `Bearer ${session.access_token}` }
|
| 196 |
+
});
|
| 197 |
+
if (res.ok) {
|
| 198 |
+
const data = await res.json();
|
| 199 |
+
if (data.length === 0) {
|
| 200 |
+
output.push('Queue status: CLEAR! 0 cards due.');
|
| 201 |
+
} else {
|
| 202 |
+
output.push(
|
| 203 |
+
`Queue status: ${data.length} card(s) due:`,
|
| 204 |
+
...data.map((r: any) => ` - LeetCode ${r.problems?.problem_number}: ${r.problems?.name} (Box ${r.box_level})`)
|
| 205 |
+
);
|
| 206 |
+
}
|
| 207 |
+
} else {
|
| 208 |
+
output.push('Error: Failed to fetch due items');
|
| 209 |
+
}
|
| 210 |
+
} catch {
|
| 211 |
+
output.push('Error: Database connection lost');
|
| 212 |
+
}
|
| 213 |
+
break;
|
| 214 |
+
|
| 215 |
+
case 'python': {
|
| 216 |
+
const subCommand = args[0]?.toLowerCase();
|
| 217 |
+
if (subCommand === 'play') {
|
| 218 |
+
output.push('Launching Daily Python Challenge window...');
|
| 219 |
+
onOpenWindow('python');
|
| 220 |
+
} else {
|
| 221 |
+
output.push('Usage: python play');
|
| 222 |
+
}
|
| 223 |
+
break;
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
case 'db': {
|
| 227 |
+
const subCommand = args[0]?.toLowerCase();
|
| 228 |
+
if (subCommand === 'init') {
|
| 229 |
+
output.push('Initializing MySQL Playground and launching window...');
|
| 230 |
+
onOpenWindow('mysql');
|
| 231 |
+
try {
|
| 232 |
+
const { data: { session } } = await supabase.auth.getSession();
|
| 233 |
+
if (!session) {
|
| 234 |
+
output.push('Error: Unauthenticated session');
|
| 235 |
+
break;
|
| 236 |
+
}
|
| 237 |
+
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/challenges/mysql/init`, {
|
| 238 |
+
method: 'POST',
|
| 239 |
+
headers: { 'Authorization': `Bearer ${session.access_token}` }
|
| 240 |
+
});
|
| 241 |
+
if (res.ok) {
|
| 242 |
+
const data = await res.json();
|
| 243 |
+
output.push(
|
| 244 |
+
`Database Session Created: "${data.title}"`,
|
| 245 |
+
`Objective: ${data.objective}`
|
| 246 |
+
);
|
| 247 |
+
} else {
|
| 248 |
+
output.push('Error: Failed to initialize DB session on backend host.');
|
| 249 |
+
}
|
| 250 |
+
} catch {
|
| 251 |
+
output.push('Error: Server connection lost.');
|
| 252 |
+
}
|
| 253 |
+
} else if (subCommand === 'status') {
|
| 254 |
+
output.push('Fetching SQL playground status...');
|
| 255 |
+
onOpenWindow('mysql');
|
| 256 |
+
try {
|
| 257 |
+
const { data: { session } } = await supabase.auth.getSession();
|
| 258 |
+
if (!session) {
|
| 259 |
+
output.push('Error: Unauthenticated session');
|
| 260 |
+
break;
|
| 261 |
+
}
|
| 262 |
+
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/challenges/mysql/status`, {
|
| 263 |
+
headers: { 'Authorization': `Bearer ${session.access_token}` }
|
| 264 |
+
});
|
| 265 |
+
if (res.ok) {
|
| 266 |
+
const data = await res.json();
|
| 267 |
+
if (data.active) {
|
| 268 |
+
output.push(
|
| 269 |
+
`Active SQL Challenge: "${data.title}"`,
|
| 270 |
+
`Objective: ${data.objective}`,
|
| 271 |
+
`Status: ${data.completed ? 'COMPLETED (Done)' : 'IN PROGRESS'}`
|
| 272 |
+
);
|
| 273 |
+
} else {
|
| 274 |
+
output.push(data.message);
|
| 275 |
+
}
|
| 276 |
+
} else {
|
| 277 |
+
output.push('Error: Failed to fetch DB status.');
|
| 278 |
+
}
|
| 279 |
+
} catch {
|
| 280 |
+
output.push('Error: Server timed out.');
|
| 281 |
+
}
|
| 282 |
+
} else if (subCommand === 'query') {
|
| 283 |
+
// Parse SQL query which resides in the remaining input
|
| 284 |
+
const sqlIdx = trimmed.toLowerCase().indexOf('query');
|
| 285 |
+
const sql = sqlIdx !== -1 ? trimmed.substring(sqlIdx + 5).trim().replace(/^["']|["']$/g, '') : '';
|
| 286 |
+
|
| 287 |
+
if (!sql) {
|
| 288 |
+
output.push('Usage: db query [SQL Statement] - Remember to wrap query in double quotes.');
|
| 289 |
+
break;
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
output.push(`Running query: ${sql}`);
|
| 293 |
+
onOpenWindow('mysql');
|
| 294 |
+
|
| 295 |
+
try {
|
| 296 |
+
const { data: { session } } = await supabase.auth.getSession();
|
| 297 |
+
if (!session) {
|
| 298 |
+
output.push('Error: Unauthenticated session');
|
| 299 |
+
break;
|
| 300 |
+
}
|
| 301 |
+
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/challenges/mysql/query`, {
|
| 302 |
+
method: 'POST',
|
| 303 |
+
headers: {
|
| 304 |
+
'Content-Type': 'application/json',
|
| 305 |
+
'Authorization': `Bearer ${session.access_token}`
|
| 306 |
+
},
|
| 307 |
+
body: JSON.stringify({ query: sql })
|
| 308 |
+
});
|
| 309 |
+
if (res.ok) {
|
| 310 |
+
const data = await res.json();
|
| 311 |
+
if (data.success) {
|
| 312 |
+
output.push(
|
| 313 |
+
`Query OK. Affected ${data.rows_affected} rows.`,
|
| 314 |
+
...formatAsciiTable(data.columns, data.rows)
|
| 315 |
+
);
|
| 316 |
+
if (data.is_correct) {
|
| 317 |
+
output.push('OBJECTIVE ACHIEVED! Congratulations.');
|
| 318 |
+
}
|
| 319 |
+
} else {
|
| 320 |
+
output.push(`SQL Error: ${data.error}`);
|
| 321 |
+
}
|
| 322 |
+
} else {
|
| 323 |
+
output.push('Error: SQL execution failed.');
|
| 324 |
+
}
|
| 325 |
+
} catch {
|
| 326 |
+
output.push('Error: Database server unreachable.');
|
| 327 |
+
}
|
| 328 |
+
} else {
|
| 329 |
+
output.push('Usage: db init | db status | db query "SELECT..."');
|
| 330 |
+
}
|
| 331 |
+
break;
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
default:
|
| 335 |
+
output.push(`Command not recognized: "${command}". Type "help" for a list of available actions.`);
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
setHistory(prev => [...prev, ...output, '']);
|
| 339 |
+
};
|
| 340 |
+
|
| 341 |
+
return (
|
| 342 |
+
<div className="w-full h-full bg-[#1E293B] text-[#4ADE80] font-code-mono p-4 flex flex-col overflow-hidden text-xs md:text-sm select-text">
|
| 343 |
+
<div ref={containerRef} className="flex-1 overflow-y-auto mb-2 custom-scrollbar space-y-1">
|
| 344 |
+
{history.map((line, idx) => (
|
| 345 |
+
<div key={idx} className="whitespace-pre-wrap leading-relaxed min-h-[1.2em]">
|
| 346 |
+
{line}
|
| 347 |
+
</div>
|
| 348 |
+
))}
|
| 349 |
+
</div>
|
| 350 |
+
<form
|
| 351 |
+
onSubmit={(e) => {
|
| 352 |
+
e.preventDefault();
|
| 353 |
+
handleCommand(input);
|
| 354 |
+
setInput('');
|
| 355 |
+
}}
|
| 356 |
+
className="flex items-center gap-1.5 border-t border-slate-700 pt-2 shrink-0 select-none"
|
| 357 |
+
>
|
| 358 |
+
<span className="font-bold text-[#ffcc00]">$</span>
|
| 359 |
+
<input
|
| 360 |
+
type="text"
|
| 361 |
+
value={input}
|
| 362 |
+
onChange={(e) => setInput(e.target.value)}
|
| 363 |
+
className="flex-1 bg-transparent text-[#4ADE80] outline-none border-none p-0 focus:ring-0 font-code-mono text-xs md:text-sm"
|
| 364 |
+
autoFocus
|
| 365 |
+
placeholder="Type command..."
|
| 366 |
+
/>
|
| 367 |
+
</form>
|
| 368 |
+
</div>
|
| 369 |
+
);
|
| 370 |
+
}
|
algospaced-ui/src/components/WindowShell.tsx
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useRef, useEffect } from 'react';
|
| 2 |
+
|
| 3 |
+
interface WindowShellProps {
|
| 4 |
+
id: string;
|
| 5 |
+
title: string;
|
| 6 |
+
isOpen: boolean;
|
| 7 |
+
isMinimized: boolean;
|
| 8 |
+
onClose: () => void;
|
| 9 |
+
onMinimize: () => void;
|
| 10 |
+
activeWindow: string;
|
| 11 |
+
setActiveWindow: (id: string) => void;
|
| 12 |
+
children: React.ReactNode;
|
| 13 |
+
icon?: string;
|
| 14 |
+
defaultWidth?: string;
|
| 15 |
+
defaultHeight?: string;
|
| 16 |
+
defaultX?: number;
|
| 17 |
+
defaultY?: number;
|
| 18 |
+
titleBarColor?: string;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export default function WindowShell({
|
| 22 |
+
id,
|
| 23 |
+
title,
|
| 24 |
+
isOpen,
|
| 25 |
+
isMinimized,
|
| 26 |
+
onClose,
|
| 27 |
+
onMinimize,
|
| 28 |
+
activeWindow,
|
| 29 |
+
setActiveWindow,
|
| 30 |
+
children,
|
| 31 |
+
icon = 'terminal',
|
| 32 |
+
defaultWidth = '700px',
|
| 33 |
+
defaultHeight = '600px',
|
| 34 |
+
defaultX,
|
| 35 |
+
defaultY,
|
| 36 |
+
titleBarColor = '#0ea5e9',
|
| 37 |
+
}: WindowShellProps) {
|
| 38 |
+
// Helper to parse default dimensions (e.g. '700px') into integer values
|
| 39 |
+
const parseDimension = (val: string): number => {
|
| 40 |
+
const num = parseInt(val, 10);
|
| 41 |
+
return isNaN(num) ? 500 : num;
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
const [position, setPosition] = useState({ x: defaultX ?? 0, y: defaultY ?? 0 });
|
| 45 |
+
const [dimensions, setDimensions] = useState({
|
| 46 |
+
width: parseDimension(defaultWidth),
|
| 47 |
+
height: parseDimension(defaultHeight)
|
| 48 |
+
});
|
| 49 |
+
|
| 50 |
+
const [isDragging, setIsDragging] = useState(false);
|
| 51 |
+
const [isResizing, setIsResizing] = useState(false);
|
| 52 |
+
|
| 53 |
+
const dragStart = useRef({ x: 0, y: 0 });
|
| 54 |
+
const resizeStart = useRef({ width: 0, height: 0, x: 0, y: 0 });
|
| 55 |
+
const windowRef = useRef<HTMLDivElement>(null);
|
| 56 |
+
|
| 57 |
+
const isActive = activeWindow === id;
|
| 58 |
+
|
| 59 |
+
useEffect(() => {
|
| 60 |
+
// If not set, randomize offset slightly to feel "tossed on the desk"
|
| 61 |
+
if (defaultX === undefined && defaultY === undefined) {
|
| 62 |
+
const randomX = Math.floor(Math.random() * 40) - 20;
|
| 63 |
+
const randomY = Math.floor(Math.random() * 40) - 20;
|
| 64 |
+
setPosition({ x: randomX, y: randomY });
|
| 65 |
+
}
|
| 66 |
+
}, [defaultX, defaultY]);
|
| 67 |
+
|
| 68 |
+
const handleMouseDown = (e: React.MouseEvent) => {
|
| 69 |
+
// Only drag on left click and not on control buttons or resize handles
|
| 70 |
+
if (e.button !== 0) return;
|
| 71 |
+
const target = e.target as HTMLElement;
|
| 72 |
+
if (target.closest('.window-control-btn') || target.closest('.window-resize-handle')) return;
|
| 73 |
+
|
| 74 |
+
setActiveWindow(id);
|
| 75 |
+
setIsDragging(true);
|
| 76 |
+
dragStart.current = {
|
| 77 |
+
x: e.clientX - position.x,
|
| 78 |
+
y: e.clientY - position.y
|
| 79 |
+
};
|
| 80 |
+
e.preventDefault();
|
| 81 |
+
};
|
| 82 |
+
|
| 83 |
+
const handleResizeMouseDown = (e: React.MouseEvent) => {
|
| 84 |
+
if (e.button !== 0) return;
|
| 85 |
+
e.stopPropagation();
|
| 86 |
+
e.preventDefault();
|
| 87 |
+
setActiveWindow(id);
|
| 88 |
+
setIsResizing(true);
|
| 89 |
+
resizeStart.current = {
|
| 90 |
+
width: dimensions.width,
|
| 91 |
+
height: dimensions.height,
|
| 92 |
+
x: e.clientX,
|
| 93 |
+
y: e.clientY
|
| 94 |
+
};
|
| 95 |
+
};
|
| 96 |
+
|
| 97 |
+
useEffect(() => {
|
| 98 |
+
const handleMouseMove = (e: MouseEvent) => {
|
| 99 |
+
if (isDragging) {
|
| 100 |
+
const newX = e.clientX - dragStart.current.x;
|
| 101 |
+
const newY = e.clientY - dragStart.current.y;
|
| 102 |
+
setPosition({ x: newX, y: newY });
|
| 103 |
+
} else if (isResizing) {
|
| 104 |
+
const deltaX = e.clientX - resizeStart.current.x;
|
| 105 |
+
const deltaY = e.clientY - resizeStart.current.y;
|
| 106 |
+
|
| 107 |
+
// Enforce minimum dimensions (350x250 px)
|
| 108 |
+
const newWidth = Math.max(350, resizeStart.current.width + deltaX);
|
| 109 |
+
const newHeight = Math.max(250, resizeStart.current.height + deltaY);
|
| 110 |
+
|
| 111 |
+
setDimensions({
|
| 112 |
+
width: newWidth,
|
| 113 |
+
height: newHeight
|
| 114 |
+
});
|
| 115 |
+
}
|
| 116 |
+
};
|
| 117 |
+
|
| 118 |
+
const handleMouseUp = () => {
|
| 119 |
+
setIsDragging(false);
|
| 120 |
+
setIsResizing(false);
|
| 121 |
+
};
|
| 122 |
+
|
| 123 |
+
if (isDragging || isResizing) {
|
| 124 |
+
document.addEventListener('mousemove', handleMouseMove);
|
| 125 |
+
document.addEventListener('mouseup', handleMouseUp);
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
return () => {
|
| 129 |
+
document.removeEventListener('mousemove', handleMouseMove);
|
| 130 |
+
document.removeEventListener('mouseup', handleMouseUp);
|
| 131 |
+
};
|
| 132 |
+
}, [isDragging, isResizing]);
|
| 133 |
+
|
| 134 |
+
if (!isOpen || isMinimized) return null;
|
| 135 |
+
|
| 136 |
+
return (
|
| 137 |
+
<div
|
| 138 |
+
ref={windowRef}
|
| 139 |
+
onMouseDown={() => setActiveWindow(id)}
|
| 140 |
+
style={{
|
| 141 |
+
width: `${dimensions.width}px`,
|
| 142 |
+
height: `${dimensions.height}px`,
|
| 143 |
+
transform: `translate(calc(-50% + ${position.x}px), calc(-50% + ${position.y}px))`,
|
| 144 |
+
zIndex: isActive ? 40 : 30,
|
| 145 |
+
}}
|
| 146 |
+
className={`absolute top-1/2 left-1/2 max-w-[98vw] max-h-[90vh] bg-paper-white border-[4px] flex flex-col overflow-hidden window-shadow shadow-[inset_2px_2px_0px_rgba(255,255,255,0.8)] transition-all ${
|
| 147 |
+
isActive ? 'border-ink-black shadow-[8px_8px_0px_0px_rgba(30,41,59,1)]' : 'border-ink-black opacity-95 shadow-[4px_4px_0px_0px_rgba(30,41,59,1)]'
|
| 148 |
+
}`}
|
| 149 |
+
>
|
| 150 |
+
{/* Title Bar */}
|
| 151 |
+
<div
|
| 152 |
+
onMouseDown={handleMouseDown}
|
| 153 |
+
style={{ backgroundColor: titleBarColor }}
|
| 154 |
+
className="border-b-[4px] border-ink-black px-4 py-2 flex justify-between items-center text-white shrink-0 cursor-move select-none"
|
| 155 |
+
>
|
| 156 |
+
<div className="flex items-center gap-2">
|
| 157 |
+
{icon && (
|
| 158 |
+
<span className="material-symbols-outlined select-none" style={{ fontVariationSettings: "'FILL' 1" }}>
|
| 159 |
+
{icon}
|
| 160 |
+
</span>
|
| 161 |
+
)}
|
| 162 |
+
<span className="font-window-title text-sm md:text-base font-bold tracking-wide select-none">{title}</span>
|
| 163 |
+
</div>
|
| 164 |
+
<div className="flex gap-1.5">
|
| 165 |
+
<button
|
| 166 |
+
onClick={(e) => {
|
| 167 |
+
e.stopPropagation();
|
| 168 |
+
onMinimize();
|
| 169 |
+
}}
|
| 170 |
+
className="window-control-btn w-7 h-7 bg-white text-ink-black border-[3px] border-ink-black flex items-center justify-center hover:bg-yellow-300 active:translate-y-0.5 active:translate-x-0.5 shadow-[2px_2px_0px_0px_rgba(30,41,59,1)] active:shadow-none transition-all cursor-pointer"
|
| 171 |
+
>
|
| 172 |
+
<span className="material-symbols-outlined text-[14px] font-bold">minimize</span>
|
| 173 |
+
</button>
|
| 174 |
+
<button
|
| 175 |
+
onClick={(e) => {
|
| 176 |
+
e.stopPropagation();
|
| 177 |
+
onClose();
|
| 178 |
+
}}
|
| 179 |
+
className="window-control-btn w-7 h-7 bg-highlight-pink text-white border-[3px] border-ink-black flex items-center justify-center hover:bg-rose-600 active:translate-y-0.5 active:translate-x-0.5 shadow-[2px_2px_0px_0px_rgba(30,41,59,1)] active:shadow-none transition-all cursor-pointer"
|
| 180 |
+
>
|
| 181 |
+
<span className="material-symbols-outlined text-[14px] font-bold">close</span>
|
| 182 |
+
</button>
|
| 183 |
+
</div>
|
| 184 |
+
</div>
|
| 185 |
+
|
| 186 |
+
{/* Window Content */}
|
| 187 |
+
<div className="flex-1 overflow-hidden relative bg-paper-white flex flex-col">
|
| 188 |
+
{children}
|
| 189 |
+
</div>
|
| 190 |
+
|
| 191 |
+
{/* Resize Handle */}
|
| 192 |
+
<div
|
| 193 |
+
onMouseDown={handleResizeMouseDown}
|
| 194 |
+
className="window-resize-handle absolute bottom-0 right-0 w-4 h-4 cursor-se-resize flex items-end justify-end p-0.5 select-none z-[50]"
|
| 195 |
+
>
|
| 196 |
+
<svg width="10" height="10" viewBox="0 0 10 10" className="text-ink-black opacity-60">
|
| 197 |
+
<line x1="8" y1="2" x2="2" y2="8" stroke="currentColor" strokeWidth="1.5" />
|
| 198 |
+
<line x1="8" y1="5" x2="5" y2="8" stroke="currentColor" strokeWidth="1.5" />
|
| 199 |
+
</svg>
|
| 200 |
+
</div>
|
| 201 |
+
</div>
|
| 202 |
+
);
|
| 203 |
+
}
|
algospaced-ui/src/index.css
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import "tailwindcss";
|
| 2 |
+
|
| 3 |
+
@theme {
|
| 4 |
+
--font-sans: 'Be Vietnam Pro', system-ui, sans-serif;
|
| 5 |
+
--font-mono: 'Courier Prime', monospace;
|
| 6 |
+
|
| 7 |
+
--font-arcade: 'Press Start 2P', monospace;
|
| 8 |
+
--font-headline-md: 'Bricolage Grotesque', sans-serif;
|
| 9 |
+
--font-body-base: 'Be Vietnam Pro', sans-serif;
|
| 10 |
+
--font-display-lg: 'Bricolage Grotesque', sans-serif;
|
| 11 |
+
--font-code-mono: 'Courier Prime', monospace;
|
| 12 |
+
--font-window-title: 'Bricolage Grotesque', sans-serif;
|
| 13 |
+
--font-icon-label: 'Press Start 2P', monospace;
|
| 14 |
+
|
| 15 |
+
--color-paper-white: #F9F8F3;
|
| 16 |
+
--color-ink-black: #1E293B;
|
| 17 |
+
--color-highlight-pink: #F472B6;
|
| 18 |
+
--color-grass-green: #4ADE80;
|
| 19 |
+
--color-trash-gray: #64748B;
|
| 20 |
+
|
| 21 |
+
--color-primary-purple: #9d4edd;
|
| 22 |
+
--color-primary-purple-hover: #b5179e;
|
| 23 |
+
--color-accent-cyan: #4cc9f0;
|
| 24 |
+
--color-accent-pink: #f72585;
|
| 25 |
+
|
| 26 |
+
--borderRadius-DEFAULT: 0.25rem;
|
| 27 |
+
--borderRadius-lg: 0.5rem;
|
| 28 |
+
--borderRadius-xl: 0.75rem;
|
| 29 |
+
--borderRadius-full: 9999px;
|
| 30 |
+
|
| 31 |
+
--spacing-border-width: 3px;
|
| 32 |
+
--spacing-gutter-md: 16px;
|
| 33 |
+
--spacing-icon-grid-gap: 24px;
|
| 34 |
+
--spacing-window-padding: 20px;
|
| 35 |
+
--spacing-desktop-margin: 32px;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/* Base style resets */
|
| 39 |
+
body {
|
| 40 |
+
background: linear-gradient(135deg, #0ea5e9 0%, #fde047 100%);
|
| 41 |
+
color: #1E293B;
|
| 42 |
+
font-family: 'Be Vietnam Pro', sans-serif;
|
| 43 |
+
overflow: hidden;
|
| 44 |
+
margin: 0;
|
| 45 |
+
padding: 0;
|
| 46 |
+
height: 100vh;
|
| 47 |
+
width: 100vw;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
/* Custom Scrollbars - Retro & Chunky */
|
| 51 |
+
::-webkit-scrollbar {
|
| 52 |
+
width: 12px;
|
| 53 |
+
height: 12px;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
::-webkit-scrollbar-track {
|
| 57 |
+
background: #F9F8F3;
|
| 58 |
+
border-left: 3px solid #1E293B;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
::-webkit-scrollbar-thumb {
|
| 62 |
+
background: #fde047;
|
| 63 |
+
border: 3px solid #1E293B;
|
| 64 |
+
border-radius: 4px;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
::-webkit-scrollbar-thumb:hover {
|
| 68 |
+
background: #ffe24c;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
/* Specific scrollbar styling for windows that scroll */
|
| 72 |
+
.custom-scrollbar::-webkit-scrollbar {
|
| 73 |
+
width: 10px;
|
| 74 |
+
height: 10px;
|
| 75 |
+
}
|
| 76 |
+
.custom-scrollbar::-webkit-scrollbar-track {
|
| 77 |
+
background: #F9F8F3;
|
| 78 |
+
border-left: 3px solid #1E293B;
|
| 79 |
+
}
|
| 80 |
+
.custom-scrollbar::-webkit-scrollbar-thumb {
|
| 81 |
+
background: #0ea5e9;
|
| 82 |
+
border: 3px solid #1E293B;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
/* Retro Shadow Effects */
|
| 86 |
+
.window-shadow {
|
| 87 |
+
box-shadow: 6px 6px 0px 0px rgba(30, 41, 59, 1);
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
.gadget-shadow {
|
| 91 |
+
box-shadow: 6px 6px 0px 0px rgba(30, 41, 59, 1);
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
/* Physical click effects */
|
| 95 |
+
.arcade-btn {
|
| 96 |
+
box-shadow: 0px 6px 0px 0px rgba(30, 41, 59, 1), inset 0px 4px 0px 0px rgba(255,255,255,0.4);
|
| 97 |
+
transition: all 0.1s ease;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
.arcade-btn:active {
|
| 101 |
+
box-shadow: 0px 0px 0px 0px rgba(30, 41, 59, 1), inset 0px 2px 0px 0px rgba(0,0,0,0.2);
|
| 102 |
+
transform: translateY(6px);
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
.icon-hover-shift {
|
| 106 |
+
transition: all 0.1s ease-in-out;
|
| 107 |
+
}
|
| 108 |
+
.icon-hover-shift:hover {
|
| 109 |
+
transform: translate(-2px, -2px);
|
| 110 |
+
}
|
| 111 |
+
.icon-hover-shift:active {
|
| 112 |
+
transform: translate(2px, 2px);
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
/* CodeMirror Custom Retro Styling */
|
| 116 |
+
.cm-editor {
|
| 117 |
+
background-color: #1E293B !important;
|
| 118 |
+
color: #4ADE80 !important;
|
| 119 |
+
font-family: 'Courier Prime', monospace !important;
|
| 120 |
+
font-size: 13px !important;
|
| 121 |
+
outline: none !important;
|
| 122 |
+
border-radius: 0.5rem;
|
| 123 |
+
border: 3px solid #1E293B;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
.cm-scroller {
|
| 127 |
+
font-family: inherit !important;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
.cm-gutters {
|
| 131 |
+
background-color: #1E293B !important;
|
| 132 |
+
border-right: 3px solid #1E293B !important;
|
| 133 |
+
color: #64748B !important;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
.cm-activeLineGutter {
|
| 137 |
+
background-color: rgba(74, 222, 128, 0.1) !important;
|
| 138 |
+
color: #4ADE80 !important;
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
.cm-activeLine {
|
| 142 |
+
background-color: rgba(74, 222, 128, 0.05) !important;
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
.cm-cursor {
|
| 146 |
+
border-left-color: #4ADE80 !important;
|
| 147 |
+
}
|
algospaced-ui/src/main.tsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { StrictMode } from 'react'
|
| 2 |
+
import { createRoot } from 'react-dom/client'
|
| 3 |
+
import './index.css'
|
| 4 |
+
import App from './App.tsx'
|
| 5 |
+
|
| 6 |
+
createRoot(document.getElementById('root')!).render(
|
| 7 |
+
<StrictMode>
|
| 8 |
+
<App />
|
| 9 |
+
</StrictMode>,
|
| 10 |
+
)
|
algospaced-ui/src/supabaseClient.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createClient } from '@supabase/supabase-js';
|
| 2 |
+
|
| 3 |
+
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || '';
|
| 4 |
+
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY || '';
|
| 5 |
+
|
| 6 |
+
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
|
algospaced-ui/tsconfig.app.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
| 4 |
+
"target": "es2023",
|
| 5 |
+
"lib": ["ES2023", "DOM"],
|
| 6 |
+
"module": "esnext",
|
| 7 |
+
"types": ["vite/client"],
|
| 8 |
+
"skipLibCheck": true,
|
| 9 |
+
|
| 10 |
+
/* Bundler mode */
|
| 11 |
+
"moduleResolution": "bundler",
|
| 12 |
+
"allowImportingTsExtensions": true,
|
| 13 |
+
"verbatimModuleSyntax": true,
|
| 14 |
+
"moduleDetection": "force",
|
| 15 |
+
"noEmit": true,
|
| 16 |
+
"jsx": "react-jsx",
|
| 17 |
+
|
| 18 |
+
/* Linting */
|
| 19 |
+
"noUnusedLocals": true,
|
| 20 |
+
"noUnusedParameters": true,
|
| 21 |
+
"erasableSyntaxOnly": true,
|
| 22 |
+
"noFallthroughCasesInSwitch": true
|
| 23 |
+
},
|
| 24 |
+
"include": ["src"]
|
| 25 |
+
}
|
algospaced-ui/tsconfig.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"files": [],
|
| 3 |
+
"references": [
|
| 4 |
+
{ "path": "./tsconfig.app.json" },
|
| 5 |
+
{ "path": "./tsconfig.node.json" }
|
| 6 |
+
]
|
| 7 |
+
}
|
algospaced-ui/tsconfig.node.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
| 4 |
+
"target": "es2023",
|
| 5 |
+
"lib": ["ES2023"],
|
| 6 |
+
"module": "esnext",
|
| 7 |
+
"types": ["node"],
|
| 8 |
+
"skipLibCheck": true,
|
| 9 |
+
|
| 10 |
+
/* Bundler mode */
|
| 11 |
+
"moduleResolution": "bundler",
|
| 12 |
+
"allowImportingTsExtensions": true,
|
| 13 |
+
"verbatimModuleSyntax": true,
|
| 14 |
+
"moduleDetection": "force",
|
| 15 |
+
"noEmit": true,
|
| 16 |
+
|
| 17 |
+
/* Linting */
|
| 18 |
+
"noUnusedLocals": true,
|
| 19 |
+
"noUnusedParameters": true,
|
| 20 |
+
"erasableSyntaxOnly": true,
|
| 21 |
+
"noFallthroughCasesInSwitch": true
|
| 22 |
+
},
|
| 23 |
+
"include": ["vite.config.ts"]
|
| 24 |
+
}
|
algospaced-ui/vite.config.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig, loadEnv } from 'vite'
|
| 2 |
+
import react from '@vitejs/plugin-react'
|
| 3 |
+
import tailwindcss from '@tailwindcss/vite'
|
| 4 |
+
|
| 5 |
+
// https://vite.dev/config/
|
| 6 |
+
export default defineConfig(({ mode }) => {
|
| 7 |
+
const env = loadEnv(mode, process.cwd(), '')
|
| 8 |
+
const rawApiUrl = env.VITE_API_BASE_URL || 'http://localhost:8000'
|
| 9 |
+
|
| 10 |
+
return {
|
| 11 |
+
plugins: [react(), tailwindcss()],
|
| 12 |
+
define: {
|
| 13 |
+
'import.meta.env.VITE_API_BASE_URL': `(() => {
|
| 14 |
+
if (${mode === 'production'}) {
|
| 15 |
+
return '';
|
| 16 |
+
}
|
| 17 |
+
const base = ${JSON.stringify(rawApiUrl)};
|
| 18 |
+
if (typeof window !== 'undefined' && window.location) {
|
| 19 |
+
const hostname = window.location.hostname;
|
| 20 |
+
if (hostname && hostname !== 'localhost' && hostname !== '127.0.0.1') {
|
| 21 |
+
return base.replace('localhost', hostname).replace('127.0.0.1', hostname);
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
return base;
|
| 25 |
+
})()`
|
| 26 |
+
},
|
| 27 |
+
server: {
|
| 28 |
+
host: true, // Expose on local network (0.0.0.0)
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
})
|
app.py
ADDED
|
@@ -0,0 +1,664 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import urllib.parse
|
| 3 |
+
from streamlit_cookies_controller import CookieController
|
| 4 |
+
import db
|
| 5 |
+
import importlib
|
| 6 |
+
importlib.reload(db)
|
| 7 |
+
import srs_logic
|
| 8 |
+
import llm_reviewer
|
| 9 |
+
import gdrive_sync
|
| 10 |
+
|
| 11 |
+
st.set_page_config(page_title="AlgoSpaced", page_icon="🧠", layout="centered")
|
| 12 |
+
|
| 13 |
+
# Initialize Cookie Controller
|
| 14 |
+
controller = CookieController()
|
| 15 |
+
|
| 16 |
+
def trigger_delayed_reload(delay_ms=500):
|
| 17 |
+
st.components.v1.html(
|
| 18 |
+
f"""
|
| 19 |
+
<script>
|
| 20 |
+
setTimeout(function() {{
|
| 21 |
+
window.parent.location.reload();
|
| 22 |
+
}}, {delay_ms});
|
| 23 |
+
</script>
|
| 24 |
+
""",
|
| 25 |
+
height=0,
|
| 26 |
+
width=0
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
JOURNEY_SEQUENCE = [
|
| 30 |
+
217, 242, 1, 49, 347, 271, 238, 36, 128, 125, 167, 15, 11, 42, 121, 3, 424, 567, 76, 239, 20,
|
| 31 |
+
155, 150, 739, 853, 84, 704, 74, 875, 153, 33, 981, 4, 206, 21, 141, 143, 19, 138, 2, 287,
|
| 32 |
+
146, 23, 25, 226, 104, 543, 110, 100, 572, 235, 102, 199, 1448, 98, 230, 105, 124, 297, 703,
|
| 33 |
+
1046, 973, 215, 621, 355, 295, 78, 39, 40, 46, 90, 22, 79, 131, 17, 51, 208, 211, 212, 200,
|
| 34 |
+
695, 133, 286, 994, 417, 130, 207, 210, 261, 323, 684, 127, 743, 332, 1584, 778, 269, 787,
|
| 35 |
+
70, 746, 198, 213, 5, 647, 91, 322, 152, 139, 300, 416, 62, 1143, 309, 518, 494, 97, 329,
|
| 36 |
+
115, 72, 312, 10, 53, 55, 45, 134, 846, 1899, 763, 678, 57, 56, 435, 252, 253, 1851, 48,
|
| 37 |
+
54, 73, 202, 66, 50, 43, 2013, 136, 191, 338, 190, 268, 371, 7
|
| 38 |
+
]
|
| 39 |
+
|
| 40 |
+
@st.dialog("Problem Details")
|
| 41 |
+
def show_problem_details_dialog(problem_number):
|
| 42 |
+
completions = db.get_user_journey_progress()
|
| 43 |
+
is_completed = problem_number in completions
|
| 44 |
+
|
| 45 |
+
problems = db.get_problems_by_number(problem_number)
|
| 46 |
+
|
| 47 |
+
if problems:
|
| 48 |
+
st.markdown(f"### LeetCode {problem_number}")
|
| 49 |
+
|
| 50 |
+
# Display each method
|
| 51 |
+
for i, p in enumerate(problems):
|
| 52 |
+
with st.expander(f"Method: {p['pattern']} ({p['difficulty']})", expanded=(i == 0)):
|
| 53 |
+
st.markdown(f"**Complexity**: Time `{p['optimal_time']}` | Space `{p['optimal_space']}`")
|
| 54 |
+
st.markdown(f"[View Google Doc]({p['google_doc_url']})")
|
| 55 |
+
|
| 56 |
+
# Review button
|
| 57 |
+
if st.button("🎯 Review This Problem", key=f"rev_{p['id']}", use_container_width=True):
|
| 58 |
+
st.session_state.active_review_problem_id = p['id']
|
| 59 |
+
st.session_state.active_tab = "🎯 Review Queue"
|
| 60 |
+
st.rerun()
|
| 61 |
+
else:
|
| 62 |
+
st.warning("No local notes found for this problem yet.")
|
| 63 |
+
st.write("You can search for this problem on LeetCode or Google:")
|
| 64 |
+
|
| 65 |
+
leetcode_url = f"https://leetcode.com/problemset/?search={problem_number}"
|
| 66 |
+
google_url = f"https://www.google.com/search?q=leetcode+{problem_number}"
|
| 67 |
+
|
| 68 |
+
st.markdown(f"- [Search on LeetCode 🌐]({leetcode_url})")
|
| 69 |
+
st.markdown(f"- [Search on Google 🔍]({google_url})")
|
| 70 |
+
|
| 71 |
+
st.markdown("---")
|
| 72 |
+
|
| 73 |
+
# Toggle viewed status
|
| 74 |
+
if is_completed:
|
| 75 |
+
st.write("Status: **Completed / Viewed** ✅")
|
| 76 |
+
if st.button("🔴 Remove from Viewed", use_container_width=True):
|
| 77 |
+
db.toggle_journey_progress(problem_number)
|
| 78 |
+
st.rerun()
|
| 79 |
+
else:
|
| 80 |
+
st.write("Status: **Not Completed / Viewed** ❌")
|
| 81 |
+
if st.button("🟢 Mark as Completed / Viewed", type="primary", use_container_width=True):
|
| 82 |
+
db.toggle_journey_progress(problem_number)
|
| 83 |
+
st.rerun()
|
| 84 |
+
|
| 85 |
+
def show_journey_page():
|
| 86 |
+
import math
|
| 87 |
+
if "show_dialog_num" in st.session_state:
|
| 88 |
+
show_problem_details_dialog(st.session_state.show_dialog_num)
|
| 89 |
+
del st.session_state.show_dialog_num
|
| 90 |
+
|
| 91 |
+
st.subheader("🗺️ Your Journey Path")
|
| 92 |
+
st.write("Visually track your algorithmic progress. Synced problems and manual completions are highlighted in **green/gold**.")
|
| 93 |
+
|
| 94 |
+
completions = db.get_user_journey_progress()
|
| 95 |
+
titles = db.get_problem_titles_by_number()
|
| 96 |
+
|
| 97 |
+
total = len(JOURNEY_SEQUENCE)
|
| 98 |
+
completed_count = len(completions.intersection(JOURNEY_SEQUENCE))
|
| 99 |
+
pct = int((completed_count / total) * 100) if total else 0
|
| 100 |
+
|
| 101 |
+
st.progress(pct / 100.0)
|
| 102 |
+
st.write(f"**Progress**: {completed_count} / {total} Problems Completed ({pct}%)")
|
| 103 |
+
|
| 104 |
+
css = """
|
| 105 |
+
<style>
|
| 106 |
+
.journey-wrapper {
|
| 107 |
+
display: flex;
|
| 108 |
+
justify-content: center;
|
| 109 |
+
width: 100%;
|
| 110 |
+
padding: 20px 0;
|
| 111 |
+
}
|
| 112 |
+
.journey-container {
|
| 113 |
+
position: relative;
|
| 114 |
+
display: flex;
|
| 115 |
+
flex-direction: column;
|
| 116 |
+
align-items: center;
|
| 117 |
+
width: 100%;
|
| 118 |
+
max-width: 450px;
|
| 119 |
+
padding: 40px 0;
|
| 120 |
+
}
|
| 121 |
+
.journey-line {
|
| 122 |
+
position: absolute;
|
| 123 |
+
top: 0;
|
| 124 |
+
bottom: 0;
|
| 125 |
+
width: 6px;
|
| 126 |
+
background: rgba(128, 128, 128, 0.2);
|
| 127 |
+
z-index: 1;
|
| 128 |
+
border-radius: 3px;
|
| 129 |
+
}
|
| 130 |
+
.journey-node {
|
| 131 |
+
position: relative;
|
| 132 |
+
display: flex;
|
| 133 |
+
align-items: center;
|
| 134 |
+
justify-content: center;
|
| 135 |
+
width: 60px;
|
| 136 |
+
height: 60px;
|
| 137 |
+
border-radius: 50%;
|
| 138 |
+
font-weight: bold;
|
| 139 |
+
font-size: 14px;
|
| 140 |
+
text-decoration: none !important;
|
| 141 |
+
z-index: 2;
|
| 142 |
+
margin: 15px 0;
|
| 143 |
+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.15);
|
| 144 |
+
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
| 145 |
+
border: 3px solid rgba(255, 255, 255, 0.2);
|
| 146 |
+
}
|
| 147 |
+
.journey-node.completed {
|
| 148 |
+
background: linear-gradient(135deg, #10b981, #f59e0b);
|
| 149 |
+
color: #ffffff !important;
|
| 150 |
+
box-shadow: 0 0 15px rgba(245, 158, 11, 0.4), 0 4px 6px rgba(0, 0, 0, 0.2);
|
| 151 |
+
}
|
| 152 |
+
.journey-node.uncompleted {
|
| 153 |
+
background: rgba(128, 128, 128, 0.15);
|
| 154 |
+
color: rgba(255, 255, 255, 0.6) !important;
|
| 155 |
+
}
|
| 156 |
+
.journey-node:hover {
|
| 157 |
+
transform: scale(1.15);
|
| 158 |
+
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3);
|
| 159 |
+
z-index: 10;
|
| 160 |
+
}
|
| 161 |
+
.journey-node.completed:hover {
|
| 162 |
+
background: linear-gradient(135deg, #059669, #d97706);
|
| 163 |
+
box-shadow: 0 0 20px rgba(217, 119, 6, 0.6);
|
| 164 |
+
}
|
| 165 |
+
.journey-node.uncompleted:hover {
|
| 166 |
+
background: rgba(128, 128, 128, 0.3);
|
| 167 |
+
color: #ffffff !important;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
.journey-node::after {
|
| 171 |
+
content: attr(data-tooltip);
|
| 172 |
+
position: absolute;
|
| 173 |
+
bottom: 125%;
|
| 174 |
+
left: 50%;
|
| 175 |
+
transform: translateX(-50%) scale(0.9);
|
| 176 |
+
background: #1e293b;
|
| 177 |
+
color: #f8fafc;
|
| 178 |
+
padding: 8px 12px;
|
| 179 |
+
border-radius: 8px;
|
| 180 |
+
font-size: 12px;
|
| 181 |
+
font-weight: 500;
|
| 182 |
+
width: 220px;
|
| 183 |
+
text-align: center;
|
| 184 |
+
opacity: 0;
|
| 185 |
+
pointer-events: none;
|
| 186 |
+
transition: all 0.18s cubic-bezier(0.4, 0, 0.2, 1);
|
| 187 |
+
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3);
|
| 188 |
+
z-index: 100;
|
| 189 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 190 |
+
}
|
| 191 |
+
.journey-node:hover::after {
|
| 192 |
+
opacity: 1;
|
| 193 |
+
transform: translateX(-50%) scale(1);
|
| 194 |
+
}
|
| 195 |
+
</style>
|
| 196 |
+
"""
|
| 197 |
+
|
| 198 |
+
html = f"{css}<div class='journey-wrapper'><div class='journey-container'><div class='journey-line'></div>"
|
| 199 |
+
|
| 200 |
+
for i, num in enumerate(JOURNEY_SEQUENCE):
|
| 201 |
+
offset = int(math.sin(i * 0.8) * 65)
|
| 202 |
+
is_completed = num in completions
|
| 203 |
+
status_class = "completed" if is_completed else "uncompleted"
|
| 204 |
+
|
| 205 |
+
title = titles.get(num)
|
| 206 |
+
if title:
|
| 207 |
+
tooltip_text = f"{num}. {title}"
|
| 208 |
+
else:
|
| 209 |
+
tooltip_text = f"Problem {num} (Not Synced)"
|
| 210 |
+
html += f'<a href="?view_num={num}" target="_self" class="journey-node {status_class}" data-tooltip="{tooltip_text}" style="position: relative; left: {offset}px;">{num}</a>'
|
| 211 |
+
|
| 212 |
+
html += "</div></div>"
|
| 213 |
+
st.markdown(html, unsafe_allow_html=True)
|
| 214 |
+
|
| 215 |
+
# Check and restore session from cookies if present and not already restored
|
| 216 |
+
client = db.get_supabase_client()
|
| 217 |
+
if "session_restored" not in st.session_state and "signed_out" not in st.session_state:
|
| 218 |
+
cookies = st.context.cookies
|
| 219 |
+
access_token = cookies.get('sb_access_token')
|
| 220 |
+
refresh_token = cookies.get('sb_refresh_token')
|
| 221 |
+
if access_token and refresh_token:
|
| 222 |
+
try:
|
| 223 |
+
client.auth.set_session(access_token, refresh_token)
|
| 224 |
+
st.session_state.session_restored = True
|
| 225 |
+
st.rerun()
|
| 226 |
+
except Exception:
|
| 227 |
+
# Token might be invalid/expired, remove them
|
| 228 |
+
controller.remove('sb_access_token')
|
| 229 |
+
controller.remove('sb_refresh_token')
|
| 230 |
+
|
| 231 |
+
def dump_cookies_diagnostics():
|
| 232 |
+
import os
|
| 233 |
+
try:
|
| 234 |
+
log_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "debug_cookies.txt")
|
| 235 |
+
cookies = st.context.cookies
|
| 236 |
+
session = client.auth.get_session()
|
| 237 |
+
user_id = session.user.id if session and session.user else None
|
| 238 |
+
|
| 239 |
+
with open(log_path, "w", encoding="utf-8") as f:
|
| 240 |
+
f.write(f"Cookies keys: {list(cookies.keys())}\n")
|
| 241 |
+
f.write(f"sb_access_token: {cookies.get('sb_access_token')[:20] if cookies.get('sb_access_token') else None}...\n")
|
| 242 |
+
f.write(f"sb_refresh_token: {cookies.get('sb_refresh_token')[:20] if cookies.get('sb_refresh_token') else None}...\n")
|
| 243 |
+
f.write(f"Session State keys: {list(st.session_state.keys())}\n")
|
| 244 |
+
f.write(f"Supabase Client User ID: {user_id}\n")
|
| 245 |
+
if "session_restored" in st.session_state:
|
| 246 |
+
f.write(f"session_restored: {st.session_state.session_restored}\n")
|
| 247 |
+
if "signed_out" in st.session_state:
|
| 248 |
+
f.write(f"signed_out: {st.session_state.signed_out}\n")
|
| 249 |
+
except Exception as e:
|
| 250 |
+
import traceback
|
| 251 |
+
with open("debug_cookies_error.txt", "w") as err_f:
|
| 252 |
+
err_f.write(f"Error: {e}\n")
|
| 253 |
+
err_f.write(traceback.format_exc())
|
| 254 |
+
|
| 255 |
+
dump_cookies_diagnostics()
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def handle_review_completion(current_review, streak_data, rating):
|
| 259 |
+
new_box = srs_logic.evaluate_new_box(current_review['box_level'], rating)
|
| 260 |
+
next_review_date = srs_logic.calculate_next_review(new_box).isoformat()
|
| 261 |
+
|
| 262 |
+
times_correct = current_review['times_correct'] + (1 if rating == "Correct" else 0)
|
| 263 |
+
total_attempts = current_review['total_attempts'] + 1
|
| 264 |
+
|
| 265 |
+
db.update_review(current_review['id'], {
|
| 266 |
+
"box_level": new_box,
|
| 267 |
+
"next_review": next_review_date,
|
| 268 |
+
"times_correct": times_correct,
|
| 269 |
+
"total_attempts": total_attempts
|
| 270 |
+
})
|
| 271 |
+
|
| 272 |
+
new_streak = srs_logic.increment_streak(streak_data)
|
| 273 |
+
db.update_streak(new_streak)
|
| 274 |
+
st.toast(f"Review saved! Moved to Box {new_box}. Next review: {next_review_date[:10]}")
|
| 275 |
+
|
| 276 |
+
def show_auth_screen():
|
| 277 |
+
st.markdown("<h2 style='text-align: center;'>Welcome to AlgoSpaced 🧠</h2>", unsafe_allow_html=True)
|
| 278 |
+
st.markdown("<p style='text-align: center; color: #888;'>A cloud-syncing Spaced Repetition system for mastering LeetCode algorithmic paradigms.</p>", unsafe_allow_html=True)
|
| 279 |
+
|
| 280 |
+
# Sign in with Google (placed prominently at the top)
|
| 281 |
+
client = db.get_supabase_client()
|
| 282 |
+
if st.button("🔴 Sign in with Google", use_container_width=True):
|
| 283 |
+
with st.spinner("Redirecting to Google..."):
|
| 284 |
+
try:
|
| 285 |
+
# 1. Trigger oauth to generate the code challenge and verifier internally
|
| 286 |
+
res = client.auth.sign_in_with_oauth({
|
| 287 |
+
"provider": "google",
|
| 288 |
+
"options": {
|
| 289 |
+
"redirect_to": "http://localhost:8501"
|
| 290 |
+
}
|
| 291 |
+
})
|
| 292 |
+
# 2. Extract code_verifier from the client's internal storage
|
| 293 |
+
code_verifier = client.auth._storage.get_item("supabase.auth.token-code-verifier")
|
| 294 |
+
if res.url and code_verifier:
|
| 295 |
+
# 3. Parse res.url and safely append verifier as a query parameter inside redirect_to URL.
|
| 296 |
+
# This avoids overriding the 'state' parameter which Supabase reserves internally.
|
| 297 |
+
parsed_url = urllib.parse.urlparse(res.url)
|
| 298 |
+
query_params = urllib.parse.parse_qs(parsed_url.query)
|
| 299 |
+
if "redirect_to" in query_params:
|
| 300 |
+
base_redirect = query_params["redirect_to"][0]
|
| 301 |
+
separator = "&" if "?" in base_redirect else "?"
|
| 302 |
+
query_params["redirect_to"] = [f"{base_redirect}{separator}verifier={code_verifier}"]
|
| 303 |
+
|
| 304 |
+
# Rebuild the final authorize URL
|
| 305 |
+
new_query = urllib.parse.urlencode(query_params, doseq=True)
|
| 306 |
+
oauth_url = urllib.parse.urlunparse(parsed_url._replace(query=new_query))
|
| 307 |
+
|
| 308 |
+
# Redirect client browser
|
| 309 |
+
st.markdown(f'<meta http-equiv="refresh" content="0; url={oauth_url}">', unsafe_allow_html=True)
|
| 310 |
+
except Exception as e:
|
| 311 |
+
st.error(f"Google login failed: {e}")
|
| 312 |
+
|
| 313 |
+
st.markdown("<div style='text-align: center; margin: 15px 0; color: #666;'>— OR —</div>", unsafe_allow_html=True)
|
| 314 |
+
|
| 315 |
+
tab1, tab2 = st.tabs(["Log In", "Sign Up"])
|
| 316 |
+
|
| 317 |
+
with tab1:
|
| 318 |
+
email = st.text_input("Email Address", key="login_email")
|
| 319 |
+
password = st.text_input("Password", type="password", key="login_password")
|
| 320 |
+
if st.button("Log In", type="primary", use_container_width=True):
|
| 321 |
+
if not email or not password:
|
| 322 |
+
st.error("Please fill in all fields.")
|
| 323 |
+
return
|
| 324 |
+
with st.spinner("Logging in..."):
|
| 325 |
+
try:
|
| 326 |
+
res = client.auth.sign_in_with_password({"email": email, "password": password})
|
| 327 |
+
if res.user:
|
| 328 |
+
st.success("Successfully logged in! Redirecting...")
|
| 329 |
+
session = client.auth.get_session()
|
| 330 |
+
if session:
|
| 331 |
+
controller.set('sb_access_token', session.access_token)
|
| 332 |
+
controller.set('sb_refresh_token', session.refresh_token)
|
| 333 |
+
if "signed_out" in st.session_state:
|
| 334 |
+
del st.session_state.signed_out
|
| 335 |
+
trigger_delayed_reload(500)
|
| 336 |
+
except Exception as e:
|
| 337 |
+
st.error(f"Login failed: {e}")
|
| 338 |
+
|
| 339 |
+
with tab2:
|
| 340 |
+
new_email = st.text_input("Email Address", key="signup_email")
|
| 341 |
+
new_password = st.text_input("Password", type="password", key="signup_password")
|
| 342 |
+
confirm_password = st.text_input("Confirm Password", type="password", key="signup_confirm_password")
|
| 343 |
+
if st.button("Create Account", use_container_width=True):
|
| 344 |
+
if not new_email or not new_password or not confirm_password:
|
| 345 |
+
st.error("Please fill in all fields.")
|
| 346 |
+
return
|
| 347 |
+
if new_password != confirm_password:
|
| 348 |
+
st.error("Passwords do not match!")
|
| 349 |
+
return
|
| 350 |
+
with st.spinner("Signing up..."):
|
| 351 |
+
try:
|
| 352 |
+
res = client.auth.sign_up({"email": new_email, "password": new_password})
|
| 353 |
+
if res.user:
|
| 354 |
+
st.success("Sign up successful! If email confirmation is enabled in Supabase, check your inbox. Otherwise, you can Log In now.")
|
| 355 |
+
session = client.auth.get_session()
|
| 356 |
+
if session:
|
| 357 |
+
controller.set('sb_access_token', session.access_token)
|
| 358 |
+
controller.set('sb_refresh_token', session.refresh_token)
|
| 359 |
+
if "signed_out" in st.session_state:
|
| 360 |
+
del st.session_state.signed_out
|
| 361 |
+
trigger_delayed_reload(500)
|
| 362 |
+
except Exception as e:
|
| 363 |
+
st.error(f"Sign up failed: {e}")
|
| 364 |
+
|
| 365 |
+
def dump_user_data_to_file():
|
| 366 |
+
import os
|
| 367 |
+
try:
|
| 368 |
+
user_id = db.get_current_user_id()
|
| 369 |
+
if user_id:
|
| 370 |
+
client = db.get_supabase_client()
|
| 371 |
+
problems = client.table('problems').select('*').eq('user_id', user_id).execute().data
|
| 372 |
+
reviews = client.table('user_reviews').select('*').eq('user_id', user_id).execute().data
|
| 373 |
+
|
| 374 |
+
log_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "debug_db.txt")
|
| 375 |
+
with open(log_path, "w", encoding="utf-8") as f:
|
| 376 |
+
f.write(f"User ID: {user_id}\n")
|
| 377 |
+
f.write(f"Problems Count: {len(problems)}\n")
|
| 378 |
+
f.write("Problems:\n")
|
| 379 |
+
for p in problems:
|
| 380 |
+
code_val = p.get('reference_code')
|
| 381 |
+
code_repr = repr(code_val[:50]) if code_val else str(code_val)
|
| 382 |
+
f.write(f" - ID: {p['id']}\n")
|
| 383 |
+
f.write(f" Name: {p['name']}\n")
|
| 384 |
+
f.write(f" Pattern: {p['pattern']}\n")
|
| 385 |
+
f.write(f" Code: {code_repr}\n")
|
| 386 |
+
f.write(f" Doc ID: {p.get('google_doc_id')}\n")
|
| 387 |
+
f.write(f" Created At: {p.get('created_at')}\n")
|
| 388 |
+
f.write(f"\nReviews Count: {len(reviews)}\n")
|
| 389 |
+
for r in reviews:
|
| 390 |
+
f.write(f" - ID: {r['id']}, Problem ID: {r['problem_id']}, Box: {r['box_level']}\n")
|
| 391 |
+
except Exception as e:
|
| 392 |
+
log_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "debug_error.txt")
|
| 393 |
+
with open(log_path, "w", encoding="utf-8") as f:
|
| 394 |
+
f.write(str(e))
|
| 395 |
+
|
| 396 |
+
def main():
|
| 397 |
+
# Check for Journey Path view query parameter
|
| 398 |
+
query_params = st.query_params
|
| 399 |
+
if "view_num" in query_params:
|
| 400 |
+
try:
|
| 401 |
+
view_num = int(query_params["view_num"])
|
| 402 |
+
st.session_state.show_dialog_num = view_num
|
| 403 |
+
st.session_state.active_tab = "🗺️ Journey Path"
|
| 404 |
+
st.query_params.clear()
|
| 405 |
+
st.rerun()
|
| 406 |
+
except Exception as e:
|
| 407 |
+
st.error(f"Error opening details modal: {e}")
|
| 408 |
+
|
| 409 |
+
# 0. Check for OAuth redirect code and verifier query parameter
|
| 410 |
+
if "code" in query_params and "verifier" in query_params:
|
| 411 |
+
auth_code = query_params["code"]
|
| 412 |
+
code_verifier = query_params["verifier"]
|
| 413 |
+
client = db.get_supabase_client()
|
| 414 |
+
with st.spinner("Completing Google authentication..."):
|
| 415 |
+
try:
|
| 416 |
+
# Seed the verifier back into the storage of the new client instance
|
| 417 |
+
client.auth._storage.set_item("supabase.auth.token-code-verifier", code_verifier)
|
| 418 |
+
# Exchange the authorization code for an active session
|
| 419 |
+
client.auth.exchange_code_for_session({
|
| 420 |
+
"auth_code": auth_code,
|
| 421 |
+
"code_verifier": code_verifier
|
| 422 |
+
})
|
| 423 |
+
session = client.auth.get_session()
|
| 424 |
+
if session:
|
| 425 |
+
controller.set('sb_access_token', session.access_token)
|
| 426 |
+
controller.set('sb_refresh_token', session.refresh_token)
|
| 427 |
+
if "signed_out" in st.session_state:
|
| 428 |
+
del st.session_state.signed_out
|
| 429 |
+
# Clear URL params
|
| 430 |
+
st.query_params.clear()
|
| 431 |
+
st.success("Successfully logged in with Google! Redirecting...")
|
| 432 |
+
trigger_delayed_reload(500)
|
| 433 |
+
except Exception as e:
|
| 434 |
+
st.error(f"Failed to exchange Google OAuth code: {e}")
|
| 435 |
+
|
| 436 |
+
# Authenticate User
|
| 437 |
+
client = db.get_supabase_client()
|
| 438 |
+
user = None
|
| 439 |
+
try:
|
| 440 |
+
session = client.auth.get_session()
|
| 441 |
+
if session and session.user:
|
| 442 |
+
user = session.user
|
| 443 |
+
except Exception:
|
| 444 |
+
pass
|
| 445 |
+
|
| 446 |
+
if not user:
|
| 447 |
+
show_auth_screen()
|
| 448 |
+
return
|
| 449 |
+
|
| 450 |
+
dump_user_data_to_file()
|
| 451 |
+
st.title("AlgoSpaced 🧠")
|
| 452 |
+
|
| 453 |
+
# 1. Lazy Check & Streak Banner
|
| 454 |
+
streak_data = db.get_active_streak()
|
| 455 |
+
if streak_data:
|
| 456 |
+
streak_data = srs_logic.lazy_check_streak(streak_data)
|
| 457 |
+
db.update_streak(streak_data)
|
| 458 |
+
|
| 459 |
+
# High visibility fluid CSS banner for streak
|
| 460 |
+
st.markdown(
|
| 461 |
+
f"""
|
| 462 |
+
<div style="background-color: #2e7b32; padding: 10px; border-radius: 5px; text-align: center; color: white; margin-bottom: 20px;">
|
| 463 |
+
<strong>🔥 Current Streak: {streak_data['current_streak']} Days | Longest: {streak_data['longest_streak']} Days</strong>
|
| 464 |
+
</div>
|
| 465 |
+
""",
|
| 466 |
+
unsafe_allow_html=True
|
| 467 |
+
)
|
| 468 |
+
|
| 469 |
+
# Navigation in Sidebar
|
| 470 |
+
if "active_tab" not in st.session_state:
|
| 471 |
+
st.session_state.active_tab = "🎯 Review Queue"
|
| 472 |
+
|
| 473 |
+
with st.sidebar:
|
| 474 |
+
st.header("Navigation")
|
| 475 |
+
app_mode = st.radio(
|
| 476 |
+
"Go to",
|
| 477 |
+
["🎯 Review Queue", "🗺️ Journey Path"],
|
| 478 |
+
index=0 if st.session_state.active_tab == "🎯 Review Queue" else 1
|
| 479 |
+
)
|
| 480 |
+
st.session_state.active_tab = app_mode
|
| 481 |
+
st.markdown("---")
|
| 482 |
+
|
| 483 |
+
st.header("Settings & Sync")
|
| 484 |
+
st.write(f"Logged in as: **{user.email}**")
|
| 485 |
+
|
| 486 |
+
if st.button("Sync Google Drive Folder", use_container_width=True):
|
| 487 |
+
with st.spinner("Syncing..."):
|
| 488 |
+
import importlib
|
| 489 |
+
importlib.reload(db)
|
| 490 |
+
importlib.reload(gdrive_sync)
|
| 491 |
+
res = gdrive_sync.sync_gdrive()
|
| 492 |
+
st.info(res)
|
| 493 |
+
|
| 494 |
+
st.markdown("---")
|
| 495 |
+
|
| 496 |
+
# Collapsible Guide and Tips Expander
|
| 497 |
+
with st.sidebar.expander("💡 Review Tips & Guide"):
|
| 498 |
+
st.markdown("""
|
| 499 |
+
### 🔄 The Core Cycle
|
| 500 |
+
AlgoSpaced uses a customized **Leitner System** to optimize retention:
|
| 501 |
+
- **Easy / Smashed It**: Moves up 1 Box (Max Box 5).
|
| 502 |
+
- **Medium / Struggles**: Stays in the current Box (resets interval).
|
| 503 |
+
- **Hard / Forgot**: Resets to **Box 1** immediately.
|
| 504 |
+
|
| 505 |
+
**Intervals**:
|
| 506 |
+
- **Box 1**: 1 day
|
| 507 |
+
- **Box 2**: 3 days
|
| 508 |
+
- **Box 3**: 7 days
|
| 509 |
+
- **Box 4**: 14 days
|
| 510 |
+
- **Box 5**: 30 days (Mastered!)
|
| 511 |
+
|
| 512 |
+
### 📝 Review Modes
|
| 513 |
+
- **AI Review**: Write code from memory. Gemini AI reviews logic, bugs, and time/space constraints.
|
| 514 |
+
- **Self-Grade (Offline)**: Quickly self-grade yourself if you have spotty connection or want a fast session.
|
| 515 |
+
|
| 516 |
+
### 📄 Note Formatting (Google Doc)
|
| 517 |
+
Ensure your shared Google Doc follows this format to sync properly:
|
| 518 |
+
```markdown
|
| 519 |
+
121. Best Time to Buy and Sell Stock
|
| 520 |
+
Easy
|
| 521 |
+
Description of the problem...
|
| 522 |
+
|
| 523 |
+
Method 1 (One Pass):
|
| 524 |
+
Time Complexity: O(N)
|
| 525 |
+
Space Complexity: O(1)
|
| 526 |
+
Code:
|
| 527 |
+
```python
|
| 528 |
+
def maxProfit(prices):
|
| 529 |
+
# your code here
|
| 530 |
+
```
|
| 531 |
+
|
| 532 |
+
Method 2 (Brute Force):
|
| 533 |
+
Time Complexity: O(N^2)
|
| 534 |
+
...
|
| 535 |
+
```
|
| 536 |
+
""")
|
| 537 |
+
|
| 538 |
+
st.markdown("---")
|
| 539 |
+
if st.button("Sign Out", use_container_width=True):
|
| 540 |
+
client.auth.sign_out()
|
| 541 |
+
controller.remove('sb_access_token')
|
| 542 |
+
controller.remove('sb_refresh_token')
|
| 543 |
+
if "session_restored" in st.session_state:
|
| 544 |
+
del st.session_state.session_restored
|
| 545 |
+
st.session_state.signed_out = True
|
| 546 |
+
trigger_delayed_reload(500)
|
| 547 |
+
|
| 548 |
+
if st.session_state.active_tab == "🗺️ Journey Path":
|
| 549 |
+
show_journey_page()
|
| 550 |
+
return
|
| 551 |
+
|
| 552 |
+
# Fetch current review card (handling custom reviews from Journey Path)
|
| 553 |
+
current_review = None
|
| 554 |
+
is_custom_review = False
|
| 555 |
+
if "active_review_problem_id" in st.session_state:
|
| 556 |
+
prob_id = st.session_state.active_review_problem_id
|
| 557 |
+
reviews = db.get_reviews_by_problem_id(prob_id)
|
| 558 |
+
if reviews:
|
| 559 |
+
current_review = reviews[0]
|
| 560 |
+
is_custom_review = True
|
| 561 |
+
else:
|
| 562 |
+
del st.session_state.active_review_problem_id
|
| 563 |
+
|
| 564 |
+
if not current_review:
|
| 565 |
+
due_reviews = db.get_due_reviews()
|
| 566 |
+
if not due_reviews:
|
| 567 |
+
st.info("🎉 You're all caught up for today! Come back tomorrow.")
|
| 568 |
+
return
|
| 569 |
+
current_review = due_reviews[0]
|
| 570 |
+
|
| 571 |
+
problem = current_review['problems']
|
| 572 |
+
|
| 573 |
+
st.subheader(f"Review: {problem['name']}")
|
| 574 |
+
if is_custom_review:
|
| 575 |
+
if st.button("🔙 Return to Daily Queue", use_container_width=True):
|
| 576 |
+
del st.session_state.active_review_problem_id
|
| 577 |
+
st.rerun()
|
| 578 |
+
st.write(f"**Pattern**: {problem['pattern']} | **Difficulty**: {problem['difficulty']}")
|
| 579 |
+
st.write(f"**Optimal Time**: {problem['optimal_time']} | **Optimal Space**: {problem['optimal_space']}")
|
| 580 |
+
if problem['google_doc_url']:
|
| 581 |
+
st.markdown(f"[View Google Doc]({problem['google_doc_url']})")
|
| 582 |
+
|
| 583 |
+
st.write(f"**Box Level**: {current_review['box_level']} (Next review was scheduled for: {current_review['next_review'][:10]})")
|
| 584 |
+
|
| 585 |
+
# Check if we have evaluation results for this card in the session state
|
| 586 |
+
eval_state_key = f"eval_{current_review['id']}"
|
| 587 |
+
|
| 588 |
+
if eval_state_key in st.session_state:
|
| 589 |
+
eval_res = st.session_state[eval_state_key]
|
| 590 |
+
|
| 591 |
+
st.markdown("---")
|
| 592 |
+
st.subheader("AI Evaluation Feedback")
|
| 593 |
+
is_correct = eval_res.get('is_correct', False)
|
| 594 |
+
|
| 595 |
+
if is_correct:
|
| 596 |
+
st.success("✅ Solution Approved! You parsed the constraints and code structure correctly.")
|
| 597 |
+
else:
|
| 598 |
+
st.error("❌ Solution struggled. Keep reviewing the paradigm.")
|
| 599 |
+
|
| 600 |
+
st.write(f"**Detected Complexity**: Time: `{eval_res.get('detected_complexity', {}).get('time', 'N/A')}` | Space: `{eval_res.get('detected_complexity', {}).get('space', 'N/A')}`")
|
| 601 |
+
|
| 602 |
+
if eval_res.get('bugs'):
|
| 603 |
+
st.warning("**Bugs / Code Issues Identified**:")
|
| 604 |
+
for bug in eval_res['bugs']:
|
| 605 |
+
st.markdown(f"- {bug}")
|
| 606 |
+
else:
|
| 607 |
+
st.write("✨ No logic bugs detected!")
|
| 608 |
+
|
| 609 |
+
if eval_res.get('key_suggestion'):
|
| 610 |
+
st.info(f"💡 **Key Suggestion**: {eval_res['key_suggestion']}")
|
| 611 |
+
|
| 612 |
+
if st.button("Next Card ➡️", type="primary", use_container_width=True):
|
| 613 |
+
rating = "Correct" if is_correct else "Incorrect"
|
| 614 |
+
handle_review_completion(current_review, streak_data, rating)
|
| 615 |
+
del st.session_state[eval_state_key]
|
| 616 |
+
st.rerun()
|
| 617 |
+
|
| 618 |
+
if st.button("🔄 Try Again (Clear Feedback)", use_container_width=True):
|
| 619 |
+
del st.session_state[eval_state_key]
|
| 620 |
+
st.rerun()
|
| 621 |
+
|
| 622 |
+
return
|
| 623 |
+
|
| 624 |
+
mode = st.radio("Review Mode", ["AI Review", "Self-Grade (Offline)"], horizontal=True)
|
| 625 |
+
|
| 626 |
+
if mode == "AI Review":
|
| 627 |
+
code = st.text_area("Write your code here from memory:", height=300)
|
| 628 |
+
if st.button("Submit Code for Review", type="primary", use_container_width=True):
|
| 629 |
+
if not code.strip():
|
| 630 |
+
st.warning("Please type your solution before submitting.")
|
| 631 |
+
return
|
| 632 |
+
with st.spinner("Evaluating with Gemini AI..."):
|
| 633 |
+
eval_res = llm_reviewer.evaluate_code(
|
| 634 |
+
problem['name'],
|
| 635 |
+
problem['pattern'],
|
| 636 |
+
problem['optimal_time'],
|
| 637 |
+
problem['optimal_space'],
|
| 638 |
+
code
|
| 639 |
+
)
|
| 640 |
+
|
| 641 |
+
if "error" in eval_res:
|
| 642 |
+
st.error(f"Error calling AI: {eval_res['error']}")
|
| 643 |
+
else:
|
| 644 |
+
st.session_state[eval_state_key] = eval_res
|
| 645 |
+
st.rerun()
|
| 646 |
+
|
| 647 |
+
else:
|
| 648 |
+
st.warning("Self-Grade Mode Active. Evaluate yourself truthfully.")
|
| 649 |
+
with st.expander("Show Reference Code"):
|
| 650 |
+
st.code(problem.get('reference_code', 'No reference code available.'), language="python")
|
| 651 |
+
|
| 652 |
+
col1, col2, col3 = st.columns(3)
|
| 653 |
+
if col1.button("Forgot (Box 1)", use_container_width=True):
|
| 654 |
+
handle_review_completion(current_review, streak_data, "Incorrect")
|
| 655 |
+
st.rerun()
|
| 656 |
+
if col2.button("Mixed (Same Box)", use_container_width=True):
|
| 657 |
+
handle_review_completion(current_review, streak_data, "Mixed")
|
| 658 |
+
st.rerun()
|
| 659 |
+
if col3.button("Smashed It (+1 Box)", type="primary", use_container_width=True):
|
| 660 |
+
handle_review_completion(current_review, streak_data, "Correct")
|
| 661 |
+
st.rerun()
|
| 662 |
+
|
| 663 |
+
if __name__ == "__main__":
|
| 664 |
+
main()
|
db.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
from supabase import create_client, Client, ClientOptions
|
| 3 |
+
from datetime import datetime, timezone
|
| 4 |
+
import os
|
| 5 |
+
|
| 6 |
+
def get_supabase_client() -> Client:
|
| 7 |
+
# Try using environment variables or st.secrets
|
| 8 |
+
url = os.environ.get("SUPABASE_URL")
|
| 9 |
+
key = os.environ.get("SUPABASE_KEY")
|
| 10 |
+
|
| 11 |
+
if not url or not key:
|
| 12 |
+
try:
|
| 13 |
+
if hasattr(st, "secrets") and st.secrets:
|
| 14 |
+
url = url or st.secrets.get("SUPABASE_URL")
|
| 15 |
+
key = key or st.secrets.get("SUPABASE_KEY")
|
| 16 |
+
except Exception:
|
| 17 |
+
pass
|
| 18 |
+
|
| 19 |
+
if not url or not key:
|
| 20 |
+
# Try loading from secrets.toml directly
|
| 21 |
+
import toml
|
| 22 |
+
try:
|
| 23 |
+
secrets_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".streamlit", "secrets.toml")
|
| 24 |
+
if os.path.exists(secrets_path):
|
| 25 |
+
secrets = toml.load(secrets_path)
|
| 26 |
+
url = url or secrets.get("SUPABASE_URL")
|
| 27 |
+
key = key or secrets.get("SUPABASE_KEY")
|
| 28 |
+
except Exception:
|
| 29 |
+
pass
|
| 30 |
+
|
| 31 |
+
if not url or not key:
|
| 32 |
+
raise ValueError("SUPABASE_URL and SUPABASE_KEY must be set in environment variables or secrets")
|
| 33 |
+
|
| 34 |
+
try:
|
| 35 |
+
if "supabase_client" not in st.session_state:
|
| 36 |
+
st.session_state.supabase_client = create_client(url, key)
|
| 37 |
+
return st.session_state.supabase_client
|
| 38 |
+
except Exception:
|
| 39 |
+
# Fallback to creating a new client if session_state is unavailable
|
| 40 |
+
return create_client(url, key)
|
| 41 |
+
|
| 42 |
+
def get_current_user_id(client=None):
|
| 43 |
+
if client is None:
|
| 44 |
+
client = get_supabase_client()
|
| 45 |
+
if hasattr(client, "current_user_id"):
|
| 46 |
+
return client.current_user_id
|
| 47 |
+
try:
|
| 48 |
+
session = client.auth.get_session()
|
| 49 |
+
if session and session.user:
|
| 50 |
+
return session.user.id
|
| 51 |
+
except Exception:
|
| 52 |
+
pass
|
| 53 |
+
return None
|
| 54 |
+
|
| 55 |
+
def get_active_streak(client=None):
|
| 56 |
+
if client is None:
|
| 57 |
+
client = get_supabase_client()
|
| 58 |
+
user_id = get_current_user_id(client)
|
| 59 |
+
if not user_id:
|
| 60 |
+
return None
|
| 61 |
+
res = client.table('user_streaks').select('*').eq('user_id', user_id).limit(1).execute()
|
| 62 |
+
if res.data:
|
| 63 |
+
return res.data[0]
|
| 64 |
+
else:
|
| 65 |
+
new_streak = {
|
| 66 |
+
"user_id": user_id,
|
| 67 |
+
"current_streak": 0,
|
| 68 |
+
"longest_streak": 0,
|
| 69 |
+
"last_active_date": None
|
| 70 |
+
}
|
| 71 |
+
res2 = client.table('user_streaks').insert(new_streak).execute()
|
| 72 |
+
return res2.data[0] if res2.data else None
|
| 73 |
+
|
| 74 |
+
def update_streak(streak_data, client=None):
|
| 75 |
+
if client is None:
|
| 76 |
+
client = get_supabase_client()
|
| 77 |
+
user_id = get_current_user_id(client)
|
| 78 |
+
if not user_id:
|
| 79 |
+
return None
|
| 80 |
+
return client.table('user_streaks').update({
|
| 81 |
+
"current_streak": streak_data["current_streak"],
|
| 82 |
+
"longest_streak": streak_data["longest_streak"],
|
| 83 |
+
"last_active_date": streak_data["last_active_date"],
|
| 84 |
+
"updated_at": datetime.now(timezone.utc).isoformat()
|
| 85 |
+
}).eq('user_id', user_id).execute()
|
| 86 |
+
|
| 87 |
+
def get_due_reviews(client=None):
|
| 88 |
+
if client is None:
|
| 89 |
+
client = get_supabase_client()
|
| 90 |
+
user_id = get_current_user_id(client)
|
| 91 |
+
if not user_id:
|
| 92 |
+
return []
|
| 93 |
+
now_iso = datetime.now(timezone.utc).isoformat()
|
| 94 |
+
res = client.table('user_reviews').select('*, problems(*)').eq('user_id', user_id).lte('next_review', now_iso).order('next_review').execute()
|
| 95 |
+
return res.data
|
| 96 |
+
|
| 97 |
+
def update_review(review_id, update_data, client=None):
|
| 98 |
+
if client is None:
|
| 99 |
+
client = get_supabase_client()
|
| 100 |
+
user_id = get_current_user_id(client)
|
| 101 |
+
if not user_id:
|
| 102 |
+
return None
|
| 103 |
+
return client.table('user_reviews').update(update_data).eq('id', review_id).eq('user_id', user_id).execute()
|
| 104 |
+
|
| 105 |
+
def log_trace(message: str):
|
| 106 |
+
import datetime
|
| 107 |
+
import os
|
| 108 |
+
try:
|
| 109 |
+
log_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "sync.log")
|
| 110 |
+
with open(log_path, "a", encoding="utf-8") as f:
|
| 111 |
+
f.write(f"[{datetime.datetime.now().isoformat()}] {message}\n")
|
| 112 |
+
except Exception:
|
| 113 |
+
pass
|
| 114 |
+
|
| 115 |
+
def insert_problem(problem_data, client=None):
|
| 116 |
+
if client is None:
|
| 117 |
+
client = get_supabase_client()
|
| 118 |
+
user_id = get_current_user_id(client)
|
| 119 |
+
if not user_id:
|
| 120 |
+
log_trace("db: insert_problem failed - no user_id")
|
| 121 |
+
return None
|
| 122 |
+
problem_data["user_id"] = user_id
|
| 123 |
+
log_trace(f"db: Inserting problem '{problem_data['name']}' with code length {len(problem_data.get('reference_code', '') or '')}")
|
| 124 |
+
res = client.table('problems').insert(problem_data).execute()
|
| 125 |
+
log_trace(f"db: Insert completed, returned ID: {res.data[0]['id'] if res.data else 'None'}")
|
| 126 |
+
return res
|
| 127 |
+
|
| 128 |
+
def update_problem(problem_id, update_data, client=None):
|
| 129 |
+
if client is None:
|
| 130 |
+
client = get_supabase_client()
|
| 131 |
+
user_id = get_current_user_id(client)
|
| 132 |
+
if not user_id:
|
| 133 |
+
log_trace("db: update_problem failed - no user_id")
|
| 134 |
+
return None
|
| 135 |
+
log_trace(f"db: Updating problem {problem_id} with keys {list(update_data.keys())} and code length {len(update_data.get('reference_code', '') or '')}")
|
| 136 |
+
res = client.table('problems').update(update_data).eq('id', problem_id).eq('user_id', user_id).execute()
|
| 137 |
+
log_trace(f"db: Update completed, returned data: {res.data}")
|
| 138 |
+
return res
|
| 139 |
+
|
| 140 |
+
def insert_review(review_data, client=None):
|
| 141 |
+
if client is None:
|
| 142 |
+
client = get_supabase_client()
|
| 143 |
+
user_id = get_current_user_id(client)
|
| 144 |
+
if not user_id:
|
| 145 |
+
return None
|
| 146 |
+
review_data["user_id"] = user_id
|
| 147 |
+
return client.table('user_reviews').insert(review_data).execute()
|
| 148 |
+
|
| 149 |
+
def get_problem_by_doc_id(doc_id, client=None):
|
| 150 |
+
if client is None:
|
| 151 |
+
client = get_supabase_client()
|
| 152 |
+
user_id = get_current_user_id(client)
|
| 153 |
+
if not user_id:
|
| 154 |
+
return None
|
| 155 |
+
res = client.table('problems').select('*').eq('user_id', user_id).eq('google_doc_id', doc_id).execute()
|
| 156 |
+
return res.data[0] if res.data else None
|
| 157 |
+
|
| 158 |
+
def get_problem_by_name(name, client=None):
|
| 159 |
+
if client is None:
|
| 160 |
+
client = get_supabase_client()
|
| 161 |
+
user_id = get_current_user_id(client)
|
| 162 |
+
if not user_id:
|
| 163 |
+
return None
|
| 164 |
+
res = client.table('problems').select('*').eq('user_id', user_id).eq('name', name).execute()
|
| 165 |
+
return res.data[0] if res.data else None
|
| 166 |
+
|
| 167 |
+
def insert_journey_progress(problem_number: int, client=None):
|
| 168 |
+
if client is None:
|
| 169 |
+
client = get_supabase_client()
|
| 170 |
+
user_id = get_current_user_id(client)
|
| 171 |
+
if not user_id:
|
| 172 |
+
return None
|
| 173 |
+
return client.table('user_journey_progress').upsert({
|
| 174 |
+
"user_id": user_id,
|
| 175 |
+
"problem_number": problem_number
|
| 176 |
+
}, on_conflict="user_id,problem_number").execute()
|
| 177 |
+
|
| 178 |
+
def delete_journey_progress(problem_number: int, client=None):
|
| 179 |
+
if client is None:
|
| 180 |
+
client = get_supabase_client()
|
| 181 |
+
user_id = get_current_user_id(client)
|
| 182 |
+
if not user_id:
|
| 183 |
+
return None
|
| 184 |
+
return client.table('user_journey_progress').delete().eq('user_id', user_id).eq('problem_number', problem_number).execute()
|
| 185 |
+
|
| 186 |
+
def toggle_journey_progress(problem_number: int, client=None):
|
| 187 |
+
if client is None:
|
| 188 |
+
client = get_supabase_client()
|
| 189 |
+
user_id = get_current_user_id(client)
|
| 190 |
+
if not user_id:
|
| 191 |
+
return None
|
| 192 |
+
res = client.table('user_journey_progress').select('id').eq('user_id', user_id).eq('problem_number', problem_number).execute()
|
| 193 |
+
if res.data:
|
| 194 |
+
return delete_journey_progress(problem_number, client)
|
| 195 |
+
else:
|
| 196 |
+
return insert_journey_progress(problem_number, client)
|
| 197 |
+
|
| 198 |
+
def get_user_journey_progress(client=None) -> set:
|
| 199 |
+
if client is None:
|
| 200 |
+
client = get_supabase_client()
|
| 201 |
+
user_id = get_current_user_id(client)
|
| 202 |
+
if not user_id:
|
| 203 |
+
return set()
|
| 204 |
+
res = client.table('user_journey_progress').select('problem_number').eq('user_id', user_id).execute()
|
| 205 |
+
return {row['problem_number'] for row in res.data} if res.data else set()
|
| 206 |
+
|
| 207 |
+
def get_problem_titles_by_number(client=None) -> dict:
|
| 208 |
+
if client is None:
|
| 209 |
+
client = get_supabase_client()
|
| 210 |
+
user_id = get_current_user_id(client)
|
| 211 |
+
if not user_id:
|
| 212 |
+
return {}
|
| 213 |
+
res = client.table('problems').select('problem_number, name').eq('user_id', user_id).execute()
|
| 214 |
+
mapping = {}
|
| 215 |
+
if res.data:
|
| 216 |
+
for row in res.data:
|
| 217 |
+
num = row.get('problem_number')
|
| 218 |
+
if num is not None:
|
| 219 |
+
base_name = row['name'].split(" - ")[0].strip()
|
| 220 |
+
mapping[num] = base_name
|
| 221 |
+
return mapping
|
| 222 |
+
|
| 223 |
+
def get_problems_by_number(problem_number: int, client=None) -> list:
|
| 224 |
+
if client is None:
|
| 225 |
+
client = get_supabase_client()
|
| 226 |
+
user_id = get_current_user_id(client)
|
| 227 |
+
if not user_id:
|
| 228 |
+
return []
|
| 229 |
+
res = client.table('problems').select('*').eq('user_id', user_id).eq('problem_number', problem_number).execute()
|
| 230 |
+
return res.data if res.data else []
|
| 231 |
+
|
| 232 |
+
def get_reviews_by_problem_id(problem_id: str, client=None) -> list:
|
| 233 |
+
if client is None:
|
| 234 |
+
client = get_supabase_client()
|
| 235 |
+
user_id = get_current_user_id(client)
|
| 236 |
+
if not user_id:
|
| 237 |
+
return []
|
| 238 |
+
res = client.table('user_reviews').select('*, problems(*)').eq('user_id', user_id).eq('problem_id', problem_id).execute()
|
| 239 |
+
return res.data if res.data else []
|
| 240 |
+
|
| 241 |
+
def get_user_score(client=None):
|
| 242 |
+
if client is None:
|
| 243 |
+
client = get_supabase_client()
|
| 244 |
+
user_id = get_current_user_id(client)
|
| 245 |
+
if not user_id:
|
| 246 |
+
return None
|
| 247 |
+
res = client.table('user_scores').select('*').eq('user_id', user_id).limit(1).execute()
|
| 248 |
+
if res.data:
|
| 249 |
+
return res.data[0]
|
| 250 |
+
else:
|
| 251 |
+
new_score = {
|
| 252 |
+
"user_id": user_id,
|
| 253 |
+
"total_score": 0,
|
| 254 |
+
"python_challenges_completed": 0,
|
| 255 |
+
"sql_challenges_completed": 0
|
| 256 |
+
}
|
| 257 |
+
try:
|
| 258 |
+
res2 = client.table('user_scores').insert(new_score).execute()
|
| 259 |
+
return res2.data[0] if res2.data else None
|
| 260 |
+
except Exception:
|
| 261 |
+
return None
|
| 262 |
+
|
| 263 |
+
def increment_user_score(points: int, challenge_type: str, client=None):
|
| 264 |
+
if client is None:
|
| 265 |
+
client = get_supabase_client()
|
| 266 |
+
user_id = get_current_user_id(client)
|
| 267 |
+
if not user_id:
|
| 268 |
+
return None
|
| 269 |
+
|
| 270 |
+
current_score = get_user_score(client)
|
| 271 |
+
if not current_score:
|
| 272 |
+
return None
|
| 273 |
+
|
| 274 |
+
update_data = {
|
| 275 |
+
"total_score": current_score["total_score"] + points,
|
| 276 |
+
"updated_at": datetime.now(timezone.utc).isoformat()
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
if challenge_type == "python":
|
| 280 |
+
update_data["python_challenges_completed"] = current_score["python_challenges_completed"] + 1
|
| 281 |
+
elif challenge_type == "mysql":
|
| 282 |
+
update_data["sql_challenges_completed"] = current_score["sql_challenges_completed"] + 1
|
| 283 |
+
|
| 284 |
+
return client.table('user_scores').update(update_data).eq('user_id', user_id).execute()
|
| 285 |
+
|
| 286 |
+
def add_challenge_history(challenge_type: str, challenge_title: str, points_awarded: int, client=None):
|
| 287 |
+
if client is None:
|
| 288 |
+
client = get_supabase_client()
|
| 289 |
+
user_id = get_current_user_id(client)
|
| 290 |
+
if not user_id:
|
| 291 |
+
return None
|
| 292 |
+
history_data = {
|
| 293 |
+
"user_id": user_id,
|
| 294 |
+
"challenge_type": challenge_type,
|
| 295 |
+
"challenge_title": challenge_title,
|
| 296 |
+
"points_awarded": points_awarded
|
| 297 |
+
}
|
| 298 |
+
return client.table('challenge_history').insert(history_data).execute()
|
| 299 |
+
|
| 300 |
+
def get_challenge_history(client=None):
|
| 301 |
+
if client is None:
|
| 302 |
+
client = get_supabase_client()
|
| 303 |
+
user_id = get_current_user_id(client)
|
| 304 |
+
if not user_id:
|
| 305 |
+
return []
|
| 306 |
+
res = client.table('challenge_history').select('*').eq('user_id', user_id).order('completed_at', desc=True).execute()
|
| 307 |
+
return res.data if res.data else []
|
| 308 |
+
|
gdrive_sync.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
import streamlit as st
|
| 3 |
+
from google.oauth2 import service_account
|
| 4 |
+
from googleapiclient.discovery import build
|
| 5 |
+
import db
|
| 6 |
+
|
| 7 |
+
def get_secret(key):
|
| 8 |
+
import os
|
| 9 |
+
# Try environment variables first
|
| 10 |
+
env_val = os.environ.get(key.upper()) or os.environ.get(key)
|
| 11 |
+
if env_val:
|
| 12 |
+
if key == "gcp_service_account" or (isinstance(env_val, str) and env_val.strip().startswith("{")):
|
| 13 |
+
import json
|
| 14 |
+
try:
|
| 15 |
+
return json.loads(env_val)
|
| 16 |
+
except Exception:
|
| 17 |
+
pass
|
| 18 |
+
return env_val
|
| 19 |
+
|
| 20 |
+
try:
|
| 21 |
+
# Try Streamlit secrets first
|
| 22 |
+
if hasattr(st, "secrets") and st.secrets and key in st.secrets:
|
| 23 |
+
return st.secrets[key]
|
| 24 |
+
except Exception:
|
| 25 |
+
pass
|
| 26 |
+
|
| 27 |
+
# Fallback to loading from toml directly
|
| 28 |
+
import toml
|
| 29 |
+
try:
|
| 30 |
+
secrets_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".streamlit", "secrets.toml")
|
| 31 |
+
if os.path.exists(secrets_path):
|
| 32 |
+
data = toml.load(secrets_path)
|
| 33 |
+
if key in data:
|
| 34 |
+
return data[key]
|
| 35 |
+
except Exception:
|
| 36 |
+
pass
|
| 37 |
+
return None
|
| 38 |
+
|
| 39 |
+
def get_credentials():
|
| 40 |
+
creds_dict = get_secret("gcp_service_account")
|
| 41 |
+
if not creds_dict:
|
| 42 |
+
raise KeyError("gcp_service_account not found in secrets")
|
| 43 |
+
return service_account.Credentials.from_service_account_info(
|
| 44 |
+
dict(creds_dict),
|
| 45 |
+
scopes=[
|
| 46 |
+
'https://www.googleapis.com/auth/drive.readonly',
|
| 47 |
+
'https://www.googleapis.com/auth/documents.readonly'
|
| 48 |
+
]
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
def extract_text_from_elements(elements):
|
| 52 |
+
text = ""
|
| 53 |
+
for value in elements:
|
| 54 |
+
if 'paragraph' in value:
|
| 55 |
+
for elem in value.get('paragraph', {}).get('elements', []):
|
| 56 |
+
if 'textRun' in elem:
|
| 57 |
+
text += elem.get('textRun', {}).get('content', '')
|
| 58 |
+
return text
|
| 59 |
+
|
| 60 |
+
def parse_problem_body(problem_name, body):
|
| 61 |
+
# Match headers like "Method 1:" or "Method 2 (Two Pointers):"
|
| 62 |
+
method_matches = list(re.finditer(r'Method\s*(\d+)(?:\s*\(([^)]+)\))?:', body, re.IGNORECASE))
|
| 63 |
+
|
| 64 |
+
difficulty = "Medium"
|
| 65 |
+
diff_match = re.search(r'^\s*(Easy|Medium|Hard)\b', body, re.IGNORECASE | re.MULTILINE)
|
| 66 |
+
if diff_match:
|
| 67 |
+
difficulty = diff_match.group(1).capitalize()
|
| 68 |
+
|
| 69 |
+
methods = []
|
| 70 |
+
|
| 71 |
+
# Extract description text: everything before the first Method match or Time Complexity line, excluding the difficulty word.
|
| 72 |
+
desc_raw = ""
|
| 73 |
+
if method_matches:
|
| 74 |
+
desc_raw = body[:method_matches[0].start()]
|
| 75 |
+
else:
|
| 76 |
+
time_match = re.search(r'Time\s+Complexity:\s*([^\n\r]+)', body, re.IGNORECASE)
|
| 77 |
+
if time_match:
|
| 78 |
+
desc_raw = body[:time_match.start()]
|
| 79 |
+
else:
|
| 80 |
+
desc_raw = body
|
| 81 |
+
|
| 82 |
+
description = re.sub(r'^\s*(Easy|Medium|Hard)\b', '', desc_raw, flags=re.IGNORECASE | re.MULTILINE).strip()
|
| 83 |
+
|
| 84 |
+
if not method_matches:
|
| 85 |
+
# Fallback for when there are no "Method X" headers (treat the whole body as one method)
|
| 86 |
+
time_match = re.search(r'Time\s+Complexity:\s*([^\n\r]+)', body, re.IGNORECASE)
|
| 87 |
+
space_match = re.search(r'Space\s+Complexity:\s*([^\n\r]+)', body, re.IGNORECASE)
|
| 88 |
+
|
| 89 |
+
optimal_time = time_match.group(1).strip() if time_match else "O(N)"
|
| 90 |
+
optimal_space = space_match.group(1).strip() if space_match else "O(N)"
|
| 91 |
+
|
| 92 |
+
code = ""
|
| 93 |
+
if space_match:
|
| 94 |
+
start_pos = space_match.end()
|
| 95 |
+
raw_code = body[start_pos:].strip()
|
| 96 |
+
|
| 97 |
+
# Improved regex to handle leading indentation/spaces/tabs
|
| 98 |
+
code_start_match = re.search(r'(?:^|\n)[ \t]*(?:def\s+|class\s+|import\s+|from\s+\S+\s+import|```[a-zA-Z]*)', raw_code)
|
| 99 |
+
if code_start_match:
|
| 100 |
+
raw_code = raw_code[code_start_match.start():].strip()
|
| 101 |
+
|
| 102 |
+
# Clean up: remove "Code:" or "Reference Code:" if explicitly present
|
| 103 |
+
raw_code = re.sub(r'^(Code|Reference Code):\s*', '', raw_code, flags=re.IGNORECASE)
|
| 104 |
+
|
| 105 |
+
# Clean up: strip markdown backticks if present
|
| 106 |
+
raw_code = re.sub(r'^```[a-zA-Z]*\s*', '', raw_code)
|
| 107 |
+
raw_code = re.sub(r'\s*```$', '', raw_code)
|
| 108 |
+
|
| 109 |
+
code = raw_code.strip()
|
| 110 |
+
|
| 111 |
+
methods.append({
|
| 112 |
+
"name": f"{problem_name} - Method 1 (Standard)",
|
| 113 |
+
"pattern": "Standard",
|
| 114 |
+
"difficulty": difficulty,
|
| 115 |
+
"optimal_time": optimal_time,
|
| 116 |
+
"optimal_space": optimal_space,
|
| 117 |
+
"code": code,
|
| 118 |
+
"description": description
|
| 119 |
+
})
|
| 120 |
+
else:
|
| 121 |
+
for j, m_match in enumerate(method_matches):
|
| 122 |
+
m_start = m_match.end()
|
| 123 |
+
m_end = method_matches[j+1].start() if j + 1 < len(method_matches) else len(body)
|
| 124 |
+
|
| 125 |
+
method_num = m_match.group(1).strip()
|
| 126 |
+
method_name = m_match.group(2).strip() if m_match.group(2) else f"Method {method_num}"
|
| 127 |
+
method_body = body[m_start:m_end]
|
| 128 |
+
|
| 129 |
+
# Complexities
|
| 130 |
+
time_match = re.search(r'Time\s+Complexity:\s*([^\n\r]+)', method_body, re.IGNORECASE)
|
| 131 |
+
space_match = re.search(r'Space\s+Complexity:\s*([^\n\r]+)', method_body, re.IGNORECASE)
|
| 132 |
+
|
| 133 |
+
optimal_time = time_match.group(1).strip() if time_match else "O(N)"
|
| 134 |
+
optimal_space = space_match.group(1).strip() if space_match else "O(N)"
|
| 135 |
+
|
| 136 |
+
# Extract code block: grab everything following the "Space Complexity" line
|
| 137 |
+
code = ""
|
| 138 |
+
if space_match:
|
| 139 |
+
start_pos = space_match.end()
|
| 140 |
+
raw_code = method_body[start_pos:].strip()
|
| 141 |
+
|
| 142 |
+
# Improved regex to handle leading indentation/spaces/tabs
|
| 143 |
+
code_start_match = re.search(r'(?:^|\n)[ \t]*(?:def\s+|class\s+|import\s+|from\s+\S+\s+import|```[a-zA-Z]*)', raw_code)
|
| 144 |
+
if code_start_match:
|
| 145 |
+
raw_code = raw_code[code_start_match.start():].strip()
|
| 146 |
+
|
| 147 |
+
# Clean up: remove "Code:" or "Reference Code:" if explicitly present
|
| 148 |
+
raw_code = re.sub(r'^(Code|Reference Code):\s*', '', raw_code, flags=re.IGNORECASE)
|
| 149 |
+
|
| 150 |
+
# Clean up: strip markdown backticks if present
|
| 151 |
+
raw_code = re.sub(r'^```[a-zA-Z]*\s*', '', raw_code)
|
| 152 |
+
raw_code = re.sub(r'\s*```$', '', raw_code)
|
| 153 |
+
|
| 154 |
+
code = raw_code.strip()
|
| 155 |
+
|
| 156 |
+
methods.append({
|
| 157 |
+
"name": f"{problem_name} - Method {method_num} ({method_name})",
|
| 158 |
+
"pattern": method_name,
|
| 159 |
+
"difficulty": difficulty,
|
| 160 |
+
"optimal_time": optimal_time,
|
| 161 |
+
"optimal_space": optimal_space,
|
| 162 |
+
"code": code,
|
| 163 |
+
"description": description
|
| 164 |
+
})
|
| 165 |
+
|
| 166 |
+
return methods
|
| 167 |
+
|
| 168 |
+
def parse_multi_problem_doc(text):
|
| 169 |
+
# Find all problem headers: e.g. "1. Two Sum"
|
| 170 |
+
matches = list(re.finditer(r'^\s*(\d+)\.\s*([^\n\r]+)', text, re.MULTILINE))
|
| 171 |
+
|
| 172 |
+
problems = []
|
| 173 |
+
for i, match in enumerate(matches):
|
| 174 |
+
start_idx = match.end()
|
| 175 |
+
end_idx = matches[i+1].start() if i + 1 < len(matches) else len(text)
|
| 176 |
+
|
| 177 |
+
problem_number = int(match.group(1).strip())
|
| 178 |
+
problem_name = match.group(2).strip()
|
| 179 |
+
problem_body = text[start_idx:end_idx]
|
| 180 |
+
|
| 181 |
+
parsed_methods = parse_problem_body(problem_name, problem_body)
|
| 182 |
+
for m in parsed_methods:
|
| 183 |
+
m['problem_number'] = problem_number
|
| 184 |
+
problems.extend(parsed_methods)
|
| 185 |
+
|
| 186 |
+
return problems
|
| 187 |
+
|
| 188 |
+
def sync_gdrive(client=None):
|
| 189 |
+
if client is None:
|
| 190 |
+
client = db.get_supabase_client()
|
| 191 |
+
|
| 192 |
+
try:
|
| 193 |
+
creds = get_credentials()
|
| 194 |
+
except KeyError:
|
| 195 |
+
return "GCP credentials not found in secrets.toml"
|
| 196 |
+
|
| 197 |
+
drive_service = build('drive', 'v3', credentials=creds)
|
| 198 |
+
docs_service = build('docs', 'v1', credentials=creds)
|
| 199 |
+
|
| 200 |
+
folder_id = get_secret("GDRIVE_FOLDER_ID")
|
| 201 |
+
if not folder_id:
|
| 202 |
+
return "GDRIVE_FOLDER_ID not found in secrets.toml"
|
| 203 |
+
|
| 204 |
+
query = f"'{folder_id}' in parents and mimeType='application/vnd.google-apps.document' and trashed=false"
|
| 205 |
+
results = drive_service.files().list(q=query, fields="nextPageToken, files(id, name)").execute()
|
| 206 |
+
items = results.get('files', [])
|
| 207 |
+
|
| 208 |
+
inserted_count = 0
|
| 209 |
+
updated_count = 0
|
| 210 |
+
|
| 211 |
+
for item in items:
|
| 212 |
+
doc_id = item['id']
|
| 213 |
+
doc_name = item['name']
|
| 214 |
+
db.log_trace(f"Sync: Processing Doc '{doc_name}' ({doc_id})")
|
| 215 |
+
|
| 216 |
+
# Fetch doc content
|
| 217 |
+
doc = docs_service.documents().get(documentId=doc_id).execute()
|
| 218 |
+
doc_content = doc.get('body').get('content')
|
| 219 |
+
full_text = extract_text_from_elements(doc_content)
|
| 220 |
+
|
| 221 |
+
parsed_problems = parse_multi_problem_doc(full_text)
|
| 222 |
+
db.log_trace(f"Sync: Parsed {len(parsed_problems)} methods from '{doc_name}'")
|
| 223 |
+
|
| 224 |
+
for p in parsed_problems:
|
| 225 |
+
# Check if this specific problem/method name exists in DB for this user
|
| 226 |
+
existing = db.get_problem_by_name(p['name'], client)
|
| 227 |
+
|
| 228 |
+
problem_data = {
|
| 229 |
+
"name": p['name'],
|
| 230 |
+
"problem_number": p.get('problem_number'),
|
| 231 |
+
"pattern": p['pattern'],
|
| 232 |
+
"difficulty": p['difficulty'],
|
| 233 |
+
"google_doc_url": f"https://docs.google.com/document/d/{doc_id}/edit",
|
| 234 |
+
"google_doc_id": doc_id,
|
| 235 |
+
"optimal_time": p['optimal_time'],
|
| 236 |
+
"optimal_space": p['optimal_space'],
|
| 237 |
+
"reference_code": p['code'],
|
| 238 |
+
"description": p.get('description', '')
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
db.log_trace(f"Sync: Method '{p['name']}' -> code length={len(p['code'])}")
|
| 242 |
+
|
| 243 |
+
if not existing:
|
| 244 |
+
# Insert problem
|
| 245 |
+
db.log_trace(f"Sync: Inserting new problem: '{p['name']}'")
|
| 246 |
+
prob_res = db.insert_problem(problem_data, client)
|
| 247 |
+
db.log_trace(f"Sync: Insert result data: {prob_res.data if prob_res else 'None'}")
|
| 248 |
+
|
| 249 |
+
# Insert review queue starting at Box 1
|
| 250 |
+
if prob_res and prob_res.data:
|
| 251 |
+
prob_id = prob_res.data[0]['id']
|
| 252 |
+
db.insert_review({
|
| 253 |
+
"problem_id": prob_id,
|
| 254 |
+
"box_level": 1
|
| 255 |
+
}, client)
|
| 256 |
+
inserted_count += 1
|
| 257 |
+
else:
|
| 258 |
+
# Update problem in case anything changed
|
| 259 |
+
db.log_trace(f"Sync: Updating existing problem: '{p['name']}' (ID={existing['id']})")
|
| 260 |
+
update_res = db.update_problem(existing['id'], {
|
| 261 |
+
"problem_number": p.get('problem_number'),
|
| 262 |
+
"pattern": p['pattern'],
|
| 263 |
+
"difficulty": p['difficulty'],
|
| 264 |
+
"optimal_time": p['optimal_time'],
|
| 265 |
+
"optimal_space": p['optimal_space'],
|
| 266 |
+
"reference_code": p['code'],
|
| 267 |
+
"description": p.get('description', '')
|
| 268 |
+
}, client)
|
| 269 |
+
db.log_trace(f"Sync: Update result data: {update_res.data if update_res else 'None'}")
|
| 270 |
+
updated_count += 1
|
| 271 |
+
|
| 272 |
+
if p.get('problem_number'):
|
| 273 |
+
db.insert_journey_progress(p['problem_number'], client)
|
| 274 |
+
|
| 275 |
+
return f"Sync complete. Created {inserted_count} new methods, updated {updated_count} existing methods."
|
| 276 |
+
|
llm_reviewer.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import streamlit as st
|
| 3 |
+
from google import genai
|
| 4 |
+
from google.genai import types
|
| 5 |
+
|
| 6 |
+
from gdrive_sync import get_secret
|
| 7 |
+
|
| 8 |
+
def generate_content_with_fallback(client, system_instruction, user_prompt, is_json=False):
|
| 9 |
+
models = [
|
| 10 |
+
'gemini-2.5-flash',
|
| 11 |
+
'gemini-3.5-flash',
|
| 12 |
+
'gemini-3-flash-preview',
|
| 13 |
+
'gemini-3.1-flash-lite'
|
| 14 |
+
]
|
| 15 |
+
|
| 16 |
+
last_err = None
|
| 17 |
+
for model_name in models:
|
| 18 |
+
try:
|
| 19 |
+
if is_json:
|
| 20 |
+
response = client.models.generate_content(
|
| 21 |
+
model=model_name,
|
| 22 |
+
contents=user_prompt,
|
| 23 |
+
config=types.GenerateContentConfig(
|
| 24 |
+
system_instruction=system_instruction,
|
| 25 |
+
response_mime_type="application/json",
|
| 26 |
+
)
|
| 27 |
+
)
|
| 28 |
+
else:
|
| 29 |
+
response = client.models.generate_content(
|
| 30 |
+
model=model_name,
|
| 31 |
+
contents=user_prompt,
|
| 32 |
+
config=types.GenerateContentConfig(
|
| 33 |
+
system_instruction=system_instruction,
|
| 34 |
+
)
|
| 35 |
+
)
|
| 36 |
+
return response
|
| 37 |
+
except Exception as e:
|
| 38 |
+
last_err = e
|
| 39 |
+
continue
|
| 40 |
+
|
| 41 |
+
raise last_err if last_err else Exception("No Gemini models succeeded")
|
| 42 |
+
|
| 43 |
+
def evaluate_code(problem_name, pattern_name, expected_time, expected_space, candidate_code):
|
| 44 |
+
api_key = get_secret("GEMINI_API_KEY")
|
| 45 |
+
if not api_key:
|
| 46 |
+
return {"error": "GEMINI_API_KEY not found in secrets"}
|
| 47 |
+
|
| 48 |
+
client = genai.Client(api_key=api_key)
|
| 49 |
+
|
| 50 |
+
system_instruction = """
|
| 51 |
+
You are an expert technical interviewer evaluating a candidate's LeetCode code written from memory.
|
| 52 |
+
Your primary objective is to review code correctness, algorithmic efficiency, and potential edge-case failures.
|
| 53 |
+
Be highly forgiving of minor visual/syntax typos (e.g., off-by-one indentation, minor spelling mistakes in variables, or missing colons)
|
| 54 |
+
which would be caught in 1 second by a standard IDE. Focus on raw algorithmic correctness.
|
| 55 |
+
Return ONLY a JSON response matching the required schema. No markdown formatting blocks around the JSON.
|
| 56 |
+
"""
|
| 57 |
+
|
| 58 |
+
user_prompt_template = f"""
|
| 59 |
+
Problem Name: {problem_name}
|
| 60 |
+
Target Pattern: {pattern_name}
|
| 61 |
+
Expected Complexity: Time: {expected_time}, Space: {expected_space}
|
| 62 |
+
|
| 63 |
+
Candidate's Code Submission:
|
| 64 |
+
{candidate_code}
|
| 65 |
+
|
| 66 |
+
Evaluate the code logic and return the structured JSON output with the exact schema:
|
| 67 |
+
{{
|
| 68 |
+
"is_correct": boolean,
|
| 69 |
+
"detected_complexity": {{"time": string, "space": string}},
|
| 70 |
+
"bugs": [string, string, ...],
|
| 71 |
+
"key_suggestion": string
|
| 72 |
+
}}
|
| 73 |
+
"""
|
| 74 |
+
|
| 75 |
+
try:
|
| 76 |
+
response = generate_content_with_fallback(client, system_instruction, user_prompt_template, is_json=True)
|
| 77 |
+
# Parse the JSON output
|
| 78 |
+
result = json.loads(response.text)
|
| 79 |
+
return result
|
| 80 |
+
except Exception as e:
|
| 81 |
+
return {"error": str(e)}
|
| 82 |
+
|
| 83 |
+
def explain_code(problem_name, code, optimal_time, optimal_space):
|
| 84 |
+
api_key = get_secret("GEMINI_API_KEY")
|
| 85 |
+
if not api_key:
|
| 86 |
+
return {"error": "GEMINI_API_KEY not found in secrets"}
|
| 87 |
+
|
| 88 |
+
client = genai.Client(api_key=api_key)
|
| 89 |
+
|
| 90 |
+
system_instruction = """
|
| 91 |
+
You are an expert technical interviewer and computer science educator.
|
| 92 |
+
Your objective is to provide a clean, comprehensive line-by-line explanation of a LeetCode reference solution.
|
| 93 |
+
|
| 94 |
+
Analyze only the provided code block text. Do NOT suggest or write an alternative code solution.
|
| 95 |
+
Break down the line-by-line logic, explain the state tracking variables, and explicitly match the code to the time and space complexities saved next to it.
|
| 96 |
+
|
| 97 |
+
Format your response in beautiful, clear Markdown.
|
| 98 |
+
"""
|
| 99 |
+
|
| 100 |
+
user_prompt = f"""
|
| 101 |
+
Problem Name: {problem_name}
|
| 102 |
+
Expected Complexity: Time: {optimal_time}, Space: {optimal_space}
|
| 103 |
+
|
| 104 |
+
Reference Code to Explain:
|
| 105 |
+
```python
|
| 106 |
+
{code}
|
| 107 |
+
```
|
| 108 |
+
|
| 109 |
+
Provide the explanation satisfying the constraints:
|
| 110 |
+
1. Break down the line-by-line logic of the code.
|
| 111 |
+
2. Explain any state tracking variables (what they hold and how they evolve).
|
| 112 |
+
3. Explicitly explain and match why the time complexity is {optimal_time} and the space complexity is {optimal_space}.
|
| 113 |
+
4. Do not write any alternative code solution.
|
| 114 |
+
"""
|
| 115 |
+
|
| 116 |
+
try:
|
| 117 |
+
response = generate_content_with_fallback(client, system_instruction, user_prompt, is_json=False)
|
| 118 |
+
return {"explanation": response.text}
|
| 119 |
+
except Exception as e:
|
| 120 |
+
return {"error": str(e)}
|
| 121 |
+
|
| 122 |
+
def generate_daily_python_challenge():
|
| 123 |
+
api_key = get_secret("GEMINI_API_KEY")
|
| 124 |
+
if not api_key:
|
| 125 |
+
return {"error": "GEMINI_API_KEY not found in secrets"}
|
| 126 |
+
|
| 127 |
+
client = genai.Client(api_key=api_key)
|
| 128 |
+
|
| 129 |
+
system_instruction = """
|
| 130 |
+
You are an expert curriculum designer for a retro-arcade coding platform called AlgoSpaced.
|
| 131 |
+
Your task is to generate a new, daily python programming challenge.
|
| 132 |
+
The challenge should test standard core DSA concepts or standard coding tasks.
|
| 133 |
+
Provide test cases that check edge cases.
|
| 134 |
+
Return ONLY a JSON response matching the required schema. No markdown formatting blocks around the JSON.
|
| 135 |
+
"""
|
| 136 |
+
|
| 137 |
+
import random
|
| 138 |
+
concepts = [
|
| 139 |
+
"arrays and hashing", "two pointers", "sliding window", "stack",
|
| 140 |
+
"binary search", "linked lists", "trees and graphs", "heap / priority queue",
|
| 141 |
+
"backtracking", "dynamic programming", "greedy algorithms", "string manipulation",
|
| 142 |
+
"math & geometry", "bit manipulation"
|
| 143 |
+
]
|
| 144 |
+
concept = random.choice(concepts)
|
| 145 |
+
|
| 146 |
+
user_prompt = f"""
|
| 147 |
+
Generate a daily coding challenge on the topic: "{concept}".
|
| 148 |
+
|
| 149 |
+
Return the structured JSON output with the exact schema:
|
| 150 |
+
{{
|
| 151 |
+
"title": "string (Short creative title, max 50 chars)",
|
| 152 |
+
"description": "string (Markdown description of the problem, input format, output format)",
|
| 153 |
+
"difficulty": "string (Easy, Medium, or Hard)",
|
| 154 |
+
"points": number (100 for Easy, 200 for Medium, 300 for Hard),
|
| 155 |
+
"starter_code": "string (Python starter code ending with 'pass')",
|
| 156 |
+
"test_cases": [
|
| 157 |
+
{{
|
| 158 |
+
"input": [any] (list of args to pass to the function),
|
| 159 |
+
"expected": any (expected return value)
|
| 160 |
+
}},
|
| 161 |
+
...
|
| 162 |
+
]
|
| 163 |
+
}}
|
| 164 |
+
|
| 165 |
+
Ensure the test cases are completely valid JSON and mathematically correct.
|
| 166 |
+
Ensure the starter_code defines a function (e.g., 'def solve(arr):\\n pass').
|
| 167 |
+
"""
|
| 168 |
+
|
| 169 |
+
try:
|
| 170 |
+
response = generate_content_with_fallback(client, system_instruction, user_prompt, is_json=True)
|
| 171 |
+
result = json.loads(response.text)
|
| 172 |
+
return result
|
| 173 |
+
except Exception as e:
|
| 174 |
+
return {"error": str(e)}
|
| 175 |
+
|
| 176 |
+
def generate_sql_challenge():
|
| 177 |
+
api_key = get_secret("GEMINI_API_KEY")
|
| 178 |
+
if not api_key:
|
| 179 |
+
return {"error": "GEMINI_API_KEY not found in secrets"}
|
| 180 |
+
|
| 181 |
+
client = genai.Client(api_key=api_key)
|
| 182 |
+
|
| 183 |
+
system_instruction = """
|
| 184 |
+
You are an expert SQL instructor for a retro-arcade coding platform called AlgoSpaced.
|
| 185 |
+
Your task is to generate an interactive SQL challenge.
|
| 186 |
+
The challenge must test standard SQL capabilities (SELECT, JOIN, GROUP BY, Window Functions, or simple mutations like UPDATE/DELETE).
|
| 187 |
+
Return ONLY a JSON response matching the required schema. No markdown formatting blocks around the JSON.
|
| 188 |
+
"""
|
| 189 |
+
|
| 190 |
+
import random
|
| 191 |
+
topics = [
|
| 192 |
+
"Window Functions (DENSE_RANK, ROW_NUMBER, PARTITION BY)",
|
| 193 |
+
"Aggregations & Grouping (SUM, AVG, HAVING)",
|
| 194 |
+
"Subqueries & CTEs (WITH clause)",
|
| 195 |
+
"Table Joins & Filtering (LEFT/INNER JOIN, NULL handling)",
|
| 196 |
+
"Data Mutations (UPDATE balances, DELETE inactive users)"
|
| 197 |
+
]
|
| 198 |
+
topic = random.choice(topics)
|
| 199 |
+
|
| 200 |
+
user_prompt = f"""
|
| 201 |
+
Generate an interactive SQL challenge on the topic: "{topic}".
|
| 202 |
+
|
| 203 |
+
Return the structured JSON output with the exact schema:
|
| 204 |
+
{{
|
| 205 |
+
"title": "string (Short creative title, max 50 chars)",
|
| 206 |
+
"objective": "string (Describe the SQL query the user needs to write. E.g., 'Write a query using DENSE_RANK() to rank employee salaries within each department.')",
|
| 207 |
+
"ddl": "string (DDL statements to create 2-3 tables. Use standard SQL types compatible with SQLite/MySQL. Ensure syntax is correct.)",
|
| 208 |
+
"inserts": "string (INSERT statements to populate the tables with 5-10 realistic mock rows.)",
|
| 209 |
+
"validation_query": "string (The correct reference SQL query that fulfills the objective.)",
|
| 210 |
+
"challenge_type": "string ('SELECT' or 'DML' (for UPDATE/DELETE challenges))",
|
| 211 |
+
"points": number (e.g., 150)
|
| 212 |
+
}}
|
| 213 |
+
|
| 214 |
+
Ensure that the DDL and INSERT statements are correct and run sequentially without errors.
|
| 215 |
+
Do not use complex MySQL-specific engines or custom functions. Keep it standard SQL.
|
| 216 |
+
"""
|
| 217 |
+
|
| 218 |
+
try:
|
| 219 |
+
response = generate_content_with_fallback(client, system_instruction, user_prompt, is_json=True)
|
| 220 |
+
result = json.loads(response.text)
|
| 221 |
+
return result
|
| 222 |
+
except Exception as e:
|
| 223 |
+
return {"error": str(e)}
|
| 224 |
+
|
| 225 |
+
|
main.py
ADDED
|
@@ -0,0 +1,727 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import toml
|
| 2 |
+
from fastapi import FastAPI, Header, HTTPException, Depends
|
| 3 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 4 |
+
from pydantic import BaseModel
|
| 5 |
+
from supabase import create_client, Client, ClientOptions
|
| 6 |
+
import db
|
| 7 |
+
import srs_logic
|
| 8 |
+
import llm_reviewer
|
| 9 |
+
import gdrive_sync
|
| 10 |
+
import sandbox
|
| 11 |
+
import sql_playground
|
| 12 |
+
from datetime import datetime, timezone
|
| 13 |
+
|
| 14 |
+
DAILY_PYTHON_CHALLENGE_CACHE = {} # maps YYYY-MM-DD to challenge dict
|
| 15 |
+
ACTIVE_SQL_CHALLENGES = {} # maps user_id to active challenge dict
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def extract_starter_code(reference_code: str) -> str:
|
| 20 |
+
if not reference_code:
|
| 21 |
+
return ""
|
| 22 |
+
|
| 23 |
+
lines = reference_code.splitlines()
|
| 24 |
+
starter_lines = []
|
| 25 |
+
|
| 26 |
+
found_class = False
|
| 27 |
+
found_def = False
|
| 28 |
+
in_def = False
|
| 29 |
+
|
| 30 |
+
for line in lines:
|
| 31 |
+
stripped = line.strip()
|
| 32 |
+
|
| 33 |
+
# If we see a class, add it
|
| 34 |
+
if stripped.startswith("class ") and not in_def:
|
| 35 |
+
starter_lines.append(line)
|
| 36 |
+
found_class = True
|
| 37 |
+
continue
|
| 38 |
+
|
| 39 |
+
# If we see def, start adding lines
|
| 40 |
+
if stripped.startswith("def ") and not in_def:
|
| 41 |
+
in_def = True
|
| 42 |
+
starter_lines.append(line)
|
| 43 |
+
check_line = stripped.split('#')[0].strip()
|
| 44 |
+
if check_line.endswith(':'):
|
| 45 |
+
in_def = False
|
| 46 |
+
indent = len(line) - len(line.lstrip())
|
| 47 |
+
starter_lines.append(" " * (indent + 4) + "pass")
|
| 48 |
+
found_def = True
|
| 49 |
+
break
|
| 50 |
+
continue
|
| 51 |
+
|
| 52 |
+
# If we are inside a multi-line def, keep adding lines
|
| 53 |
+
if in_def:
|
| 54 |
+
starter_lines.append(line)
|
| 55 |
+
check_line = stripped.split('#')[0].strip()
|
| 56 |
+
if check_line.endswith(':'):
|
| 57 |
+
in_def = False
|
| 58 |
+
def_indent = 4
|
| 59 |
+
for l in reversed(starter_lines):
|
| 60 |
+
if l.strip().startswith("def "):
|
| 61 |
+
def_indent = len(l) - len(l.lstrip())
|
| 62 |
+
break
|
| 63 |
+
starter_lines.append(" " * (def_indent + 4) + "pass")
|
| 64 |
+
found_def = True
|
| 65 |
+
break
|
| 66 |
+
|
| 67 |
+
if not starter_lines:
|
| 68 |
+
return reference_code
|
| 69 |
+
|
| 70 |
+
return "\n".join(starter_lines)
|
| 71 |
+
|
| 72 |
+
app = FastAPI(title="AlgoSpaced API")
|
| 73 |
+
|
| 74 |
+
# Configure CORS
|
| 75 |
+
app.add_middleware(
|
| 76 |
+
CORSMiddleware,
|
| 77 |
+
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"], # Vite React Dev Server
|
| 78 |
+
allow_origin_regex=r"http://(localhost|127\.0\.0\.1|192\.168\.\d+\.\d+|10\.\d+\.\d+\.\d+|172\.(1[6-9]|2\d|3[0-1])\.\d+\.\d+):5173",
|
| 79 |
+
allow_credentials=True,
|
| 80 |
+
allow_methods=["*"],
|
| 81 |
+
allow_headers=["*"],
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
import os
|
| 85 |
+
|
| 86 |
+
# Load secrets for Supabase configuration
|
| 87 |
+
SUPABASE_URL = os.environ.get("SUPABASE_URL")
|
| 88 |
+
SUPABASE_KEY = os.environ.get("SUPABASE_KEY")
|
| 89 |
+
|
| 90 |
+
if not SUPABASE_URL or not SUPABASE_KEY:
|
| 91 |
+
try:
|
| 92 |
+
secrets_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".streamlit", "secrets.toml")
|
| 93 |
+
if os.path.exists(secrets_path):
|
| 94 |
+
secrets = toml.load(secrets_path)
|
| 95 |
+
SUPABASE_URL = SUPABASE_URL or secrets.get("SUPABASE_URL")
|
| 96 |
+
SUPABASE_KEY = SUPABASE_KEY or secrets.get("SUPABASE_KEY")
|
| 97 |
+
except Exception as e:
|
| 98 |
+
print(f"Error loading secrets.toml: {e}")
|
| 99 |
+
|
| 100 |
+
# Dependency to resolve a Supabase client configured for the specific request user session
|
| 101 |
+
def get_supabase_client(authorization: str = Header(None)) -> Client:
|
| 102 |
+
if not authorization or not authorization.startswith("Bearer "):
|
| 103 |
+
raise HTTPException(status_code=401, detail="Missing or invalid Authorization header")
|
| 104 |
+
access_token = authorization.split(" ")[1]
|
| 105 |
+
|
| 106 |
+
try:
|
| 107 |
+
# Initialize a Supabase client authenticated as this specific user
|
| 108 |
+
client = create_client(
|
| 109 |
+
SUPABASE_URL,
|
| 110 |
+
SUPABASE_KEY,
|
| 111 |
+
options=ClientOptions(headers={"Authorization": f"Bearer {access_token}"})
|
| 112 |
+
)
|
| 113 |
+
# Validate session actively by querying user details
|
| 114 |
+
user_res = client.auth.get_user(access_token)
|
| 115 |
+
if not user_res or not user_res.user:
|
| 116 |
+
raise HTTPException(status_code=401, detail="Invalid Supabase authentication session")
|
| 117 |
+
client.current_user_id = user_res.user.id
|
| 118 |
+
return client
|
| 119 |
+
except Exception as e:
|
| 120 |
+
raise HTTPException(status_code=401, detail=f"Authentication failed: {str(e)}")
|
| 121 |
+
|
| 122 |
+
@app.get("/api/streak")
|
| 123 |
+
def get_streak(client: Client = Depends(get_supabase_client)):
|
| 124 |
+
streak_data = db.get_active_streak(client)
|
| 125 |
+
if streak_data:
|
| 126 |
+
streak_data = srs_logic.lazy_check_streak(streak_data)
|
| 127 |
+
db.update_streak(streak_data, client)
|
| 128 |
+
return streak_data
|
| 129 |
+
|
| 130 |
+
@app.get("/api/reviews/due")
|
| 131 |
+
def get_due_reviews(client: Client = Depends(get_supabase_client)):
|
| 132 |
+
reviews = db.get_due_reviews(client)
|
| 133 |
+
for review in reviews:
|
| 134 |
+
if "problems" in review and review["problems"]:
|
| 135 |
+
prob = review["problems"]
|
| 136 |
+
prob["starter_code"] = extract_starter_code(prob.get("reference_code", ""))
|
| 137 |
+
return reviews
|
| 138 |
+
|
| 139 |
+
@app.get("/api/reviews/problems/{problem_number}")
|
| 140 |
+
def get_problems_by_number(problem_number: int, client: Client = Depends(get_supabase_client)):
|
| 141 |
+
problems = db.get_problems_by_number(problem_number, client)
|
| 142 |
+
for prob in problems:
|
| 143 |
+
prob["starter_code"] = extract_starter_code(prob.get("reference_code", ""))
|
| 144 |
+
return problems
|
| 145 |
+
|
| 146 |
+
@app.get("/api/reviews/problems/id/{problem_id}")
|
| 147 |
+
def get_reviews_by_problem_id(problem_id: str, client: Client = Depends(get_supabase_client)):
|
| 148 |
+
reviews = db.get_reviews_by_problem_id(problem_id, client)
|
| 149 |
+
for review in reviews:
|
| 150 |
+
if "problems" in review and review["problems"]:
|
| 151 |
+
prob = review["problems"]
|
| 152 |
+
prob["starter_code"] = extract_starter_code(prob.get("reference_code", ""))
|
| 153 |
+
return reviews
|
| 154 |
+
|
| 155 |
+
class CompleteReviewRequest(BaseModel):
|
| 156 |
+
review_id: str
|
| 157 |
+
box_level: int
|
| 158 |
+
times_correct: int
|
| 159 |
+
total_attempts: int
|
| 160 |
+
rating: str # "Correct", "Mixed", "Incorrect"
|
| 161 |
+
|
| 162 |
+
@app.post("/api/reviews/complete")
|
| 163 |
+
def complete_review(req: CompleteReviewRequest, client: Client = Depends(get_supabase_client)):
|
| 164 |
+
new_box = srs_logic.evaluate_new_box(req.box_level, req.rating)
|
| 165 |
+
next_review_date = srs_logic.calculate_next_review(new_box).isoformat()
|
| 166 |
+
|
| 167 |
+
new_times_correct = req.times_correct + (1 if req.rating == "Correct" else 0)
|
| 168 |
+
new_total_attempts = req.total_attempts + 1
|
| 169 |
+
|
| 170 |
+
db.update_review(req.review_id, {
|
| 171 |
+
"box_level": new_box,
|
| 172 |
+
"next_review": next_review_date,
|
| 173 |
+
"times_correct": new_times_correct,
|
| 174 |
+
"total_attempts": new_total_attempts
|
| 175 |
+
}, client)
|
| 176 |
+
|
| 177 |
+
streak_data = db.get_active_streak(client)
|
| 178 |
+
if streak_data:
|
| 179 |
+
new_streak = srs_logic.increment_streak(streak_data)
|
| 180 |
+
db.update_streak(new_streak, client)
|
| 181 |
+
|
| 182 |
+
return {
|
| 183 |
+
"success": True,
|
| 184 |
+
"new_box": new_box,
|
| 185 |
+
"next_review": next_review_date
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
class EvaluateCodeRequest(BaseModel):
|
| 189 |
+
problem_name: str
|
| 190 |
+
pattern: str
|
| 191 |
+
optimal_time: str
|
| 192 |
+
optimal_space: str
|
| 193 |
+
code: str
|
| 194 |
+
|
| 195 |
+
@app.post("/api/reviews/evaluate")
|
| 196 |
+
def evaluate_code(req: EvaluateCodeRequest, client: Client = Depends(get_supabase_client)):
|
| 197 |
+
eval_res = llm_reviewer.evaluate_code(
|
| 198 |
+
req.problem_name,
|
| 199 |
+
req.pattern,
|
| 200 |
+
req.optimal_time,
|
| 201 |
+
req.optimal_space,
|
| 202 |
+
req.code
|
| 203 |
+
)
|
| 204 |
+
if "error" in eval_res:
|
| 205 |
+
raise HTTPException(status_code=500, detail=eval_res["error"])
|
| 206 |
+
return eval_res
|
| 207 |
+
|
| 208 |
+
class ExplainCodeRequest(BaseModel):
|
| 209 |
+
problem_number: int
|
| 210 |
+
method: str
|
| 211 |
+
|
| 212 |
+
@app.post("/api/reviews/explain")
|
| 213 |
+
def explain_review_code(req: ExplainCodeRequest, client: Client = Depends(get_supabase_client)):
|
| 214 |
+
try:
|
| 215 |
+
user_id = db.get_current_user_id(client)
|
| 216 |
+
if not user_id:
|
| 217 |
+
raise HTTPException(status_code=401, detail="Authentication failed")
|
| 218 |
+
|
| 219 |
+
res = client.table('problems').select('*').eq('user_id', user_id).eq('problem_number', req.problem_number).execute()
|
| 220 |
+
if not res.data:
|
| 221 |
+
raise HTTPException(status_code=404, detail=f"No local entry found for LeetCode {req.problem_number}")
|
| 222 |
+
|
| 223 |
+
problem = None
|
| 224 |
+
for p in res.data:
|
| 225 |
+
pattern = p.get('pattern', '')
|
| 226 |
+
name = p.get('name', '')
|
| 227 |
+
if pattern.lower() == req.method.lower() or name.lower() == req.method.lower():
|
| 228 |
+
problem = p
|
| 229 |
+
break
|
| 230 |
+
|
| 231 |
+
if not problem:
|
| 232 |
+
problem = res.data[0]
|
| 233 |
+
|
| 234 |
+
reference_code = problem.get('reference_code')
|
| 235 |
+
if not reference_code:
|
| 236 |
+
raise HTTPException(status_code=400, detail="No reference code stored for this problem")
|
| 237 |
+
|
| 238 |
+
optimal_time = problem.get('optimal_time', 'O(N)')
|
| 239 |
+
optimal_space = problem.get('optimal_space', 'O(N)')
|
| 240 |
+
problem_name = problem.get('name', '')
|
| 241 |
+
|
| 242 |
+
eval_res = llm_reviewer.explain_code(
|
| 243 |
+
problem_name=problem_name,
|
| 244 |
+
code=reference_code,
|
| 245 |
+
optimal_time=optimal_time,
|
| 246 |
+
optimal_space=optimal_space
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
if "error" in eval_res:
|
| 250 |
+
raise HTTPException(status_code=500, detail=eval_res["error"])
|
| 251 |
+
|
| 252 |
+
return eval_res
|
| 253 |
+
except HTTPException:
|
| 254 |
+
raise
|
| 255 |
+
except Exception as e:
|
| 256 |
+
import traceback
|
| 257 |
+
db.log_trace(f"Explain endpoint error: {str(e)}\n{traceback.format_exc()}")
|
| 258 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 259 |
+
|
| 260 |
+
@app.get("/api/journey-progress")
|
| 261 |
+
def get_journey_progress(client: Client = Depends(get_supabase_client)):
|
| 262 |
+
return list(db.get_user_journey_progress(client))
|
| 263 |
+
|
| 264 |
+
class ToggleJourneyRequest(BaseModel):
|
| 265 |
+
problem_number: int
|
| 266 |
+
|
| 267 |
+
@app.post("/api/journey-progress/toggle")
|
| 268 |
+
def toggle_journey(req: ToggleJourneyRequest, client: Client = Depends(get_supabase_client)):
|
| 269 |
+
db.toggle_journey_progress(req.problem_number, client)
|
| 270 |
+
return {"success": True}
|
| 271 |
+
|
| 272 |
+
@app.get("/api/journey-progress/titles")
|
| 273 |
+
def get_journey_titles(client: Client = Depends(get_supabase_client)):
|
| 274 |
+
return db.get_problem_titles_by_number(client)
|
| 275 |
+
|
| 276 |
+
@app.post("/api/sync")
|
| 277 |
+
def trigger_sync(client: Client = Depends(get_supabase_client)):
|
| 278 |
+
try:
|
| 279 |
+
res = gdrive_sync.sync_gdrive(client)
|
| 280 |
+
return {"message": res}
|
| 281 |
+
except Exception as e:
|
| 282 |
+
import traceback
|
| 283 |
+
err_msg = f"Sync endpoint exception: {str(e)}\n{traceback.format_exc()}"
|
| 284 |
+
db.log_trace(err_msg)
|
| 285 |
+
raise HTTPException(status_code=500, detail=err_msg)
|
| 286 |
+
|
| 287 |
+
class SubmitPythonRequest(BaseModel):
|
| 288 |
+
code: str
|
| 289 |
+
|
| 290 |
+
@app.get("/api/challenges/python/daily")
|
| 291 |
+
def get_daily_python_challenge(client: Client = Depends(get_supabase_client)):
|
| 292 |
+
today_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
| 293 |
+
|
| 294 |
+
if today_str not in DAILY_PYTHON_CHALLENGE_CACHE:
|
| 295 |
+
challenge = llm_reviewer.generate_daily_python_challenge()
|
| 296 |
+
if "error" in challenge:
|
| 297 |
+
raise HTTPException(status_code=500, detail=f"Failed to generate challenge: {challenge['error']}")
|
| 298 |
+
DAILY_PYTHON_CHALLENGE_CACHE[today_str] = challenge
|
| 299 |
+
|
| 300 |
+
challenge = DAILY_PYTHON_CHALLENGE_CACHE[today_str]
|
| 301 |
+
|
| 302 |
+
user_id = db.get_current_user_id(client)
|
| 303 |
+
completed = False
|
| 304 |
+
if user_id:
|
| 305 |
+
try:
|
| 306 |
+
res = client.table('challenge_history')\
|
| 307 |
+
.select('id')\
|
| 308 |
+
.eq('user_id', user_id)\
|
| 309 |
+
.eq('challenge_type', 'python')\
|
| 310 |
+
.eq('challenge_title', challenge.get("title", ""))\
|
| 311 |
+
.execute()
|
| 312 |
+
completed = len(res.data) > 0 if res.data else False
|
| 313 |
+
except Exception:
|
| 314 |
+
pass
|
| 315 |
+
|
| 316 |
+
score_record = db.get_user_score(client)
|
| 317 |
+
total_score = score_record["total_score"] if score_record else 0
|
| 318 |
+
|
| 319 |
+
return {
|
| 320 |
+
"title": challenge.get("title"),
|
| 321 |
+
"description": challenge.get("description"),
|
| 322 |
+
"difficulty": challenge.get("difficulty"),
|
| 323 |
+
"points": challenge.get("points"),
|
| 324 |
+
"starter_code": challenge.get("starter_code"),
|
| 325 |
+
"completed": completed,
|
| 326 |
+
"total_score": total_score
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
@app.post("/api/challenges/python/submit")
|
| 330 |
+
def submit_python_challenge(req: SubmitPythonRequest, client: Client = Depends(get_supabase_client)):
|
| 331 |
+
today_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
| 332 |
+
|
| 333 |
+
if today_str not in DAILY_PYTHON_CHALLENGE_CACHE:
|
| 334 |
+
challenge = llm_reviewer.generate_daily_python_challenge()
|
| 335 |
+
if "error" in challenge:
|
| 336 |
+
raise HTTPException(status_code=500, detail=f"No challenge available: {challenge['error']}")
|
| 337 |
+
DAILY_PYTHON_CHALLENGE_CACHE[today_str] = challenge
|
| 338 |
+
|
| 339 |
+
challenge = DAILY_PYTHON_CHALLENGE_CACHE[today_str]
|
| 340 |
+
test_cases = challenge.get("test_cases", [])
|
| 341 |
+
|
| 342 |
+
run_res = sandbox.run_python_sandbox(req.code, test_cases)
|
| 343 |
+
|
| 344 |
+
if not run_res.get("success", False):
|
| 345 |
+
return {
|
| 346 |
+
"success": False,
|
| 347 |
+
"error": run_res.get("error", "Unknown execution error"),
|
| 348 |
+
"sandbox_type": run_res.get("sandbox_type")
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
results = run_res.get("results", [])
|
| 352 |
+
all_passed = len(results) > 0 and all(r.get("status") == "passed" for r in results)
|
| 353 |
+
|
| 354 |
+
points_awarded = 0
|
| 355 |
+
completed_before = False
|
| 356 |
+
total_score = 0
|
| 357 |
+
|
| 358 |
+
user_id = db.get_current_user_id(client)
|
| 359 |
+
if all_passed and user_id:
|
| 360 |
+
title = challenge.get("title", "Daily Python Challenge")
|
| 361 |
+
points = challenge.get("points", 100)
|
| 362 |
+
|
| 363 |
+
try:
|
| 364 |
+
hist_res = client.table('challenge_history')\
|
| 365 |
+
.select('id')\
|
| 366 |
+
.eq('user_id', user_id)\
|
| 367 |
+
.eq('challenge_type', 'python')\
|
| 368 |
+
.eq('challenge_title', title)\
|
| 369 |
+
.execute()
|
| 370 |
+
completed_before = len(hist_res.data) > 0 if hist_res.data else False
|
| 371 |
+
except Exception:
|
| 372 |
+
pass
|
| 373 |
+
|
| 374 |
+
if not completed_before:
|
| 375 |
+
db.increment_user_score(points, "python", client)
|
| 376 |
+
db.add_challenge_history("python", title, points, client)
|
| 377 |
+
points_awarded = points
|
| 378 |
+
|
| 379 |
+
score_record = db.get_user_score(client)
|
| 380 |
+
if score_record:
|
| 381 |
+
total_score = score_record["total_score"]
|
| 382 |
+
|
| 383 |
+
return {
|
| 384 |
+
"success": True,
|
| 385 |
+
"all_passed": all_passed,
|
| 386 |
+
"results": results,
|
| 387 |
+
"points_awarded": points_awarded,
|
| 388 |
+
"completed_before": completed_before,
|
| 389 |
+
"total_score": total_score,
|
| 390 |
+
"sandbox_type": run_res.get("sandbox_type"),
|
| 391 |
+
"sandbox_warning": run_res.get("sandbox_warning")
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
class SQLQueryRequest(BaseModel):
|
| 396 |
+
query: str
|
| 397 |
+
|
| 398 |
+
@app.post("/api/challenges/mysql/init")
|
| 399 |
+
def init_mysql_challenge(client: Client = Depends(get_supabase_client)):
|
| 400 |
+
import os
|
| 401 |
+
user_id = db.get_current_user_id(client)
|
| 402 |
+
if not user_id:
|
| 403 |
+
raise HTTPException(status_code=401, detail="Authentication failed")
|
| 404 |
+
|
| 405 |
+
challenge = llm_reviewer.generate_sql_challenge()
|
| 406 |
+
if "error" in challenge:
|
| 407 |
+
raise HTTPException(status_code=500, detail=f"Failed to generate SQL challenge: {challenge['error']}")
|
| 408 |
+
|
| 409 |
+
init_res = sql_playground.init_user_db(user_id, challenge)
|
| 410 |
+
if not init_res.get("success", False):
|
| 411 |
+
raise HTTPException(status_code=500, detail=f"Failed to initialize database: {init_res.get('error')}")
|
| 412 |
+
|
| 413 |
+
ACTIVE_SQL_CHALLENGES[user_id] = challenge
|
| 414 |
+
|
| 415 |
+
score_record = db.get_user_score(client)
|
| 416 |
+
total_score = score_record["total_score"] if score_record else 0
|
| 417 |
+
|
| 418 |
+
return {
|
| 419 |
+
"success": True,
|
| 420 |
+
"title": challenge.get("title"),
|
| 421 |
+
"objective": challenge.get("objective"),
|
| 422 |
+
"points": challenge.get("points"),
|
| 423 |
+
"schema": init_res.get("schema"),
|
| 424 |
+
"challenge_type": challenge.get("challenge_type"),
|
| 425 |
+
"total_score": total_score
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
@app.get("/api/challenges/mysql/status")
|
| 429 |
+
def get_mysql_status(client: Client = Depends(get_supabase_client)):
|
| 430 |
+
import os
|
| 431 |
+
user_id = db.get_current_user_id(client)
|
| 432 |
+
if not user_id:
|
| 433 |
+
raise HTTPException(status_code=401, detail="Authentication failed")
|
| 434 |
+
|
| 435 |
+
if user_id not in ACTIVE_SQL_CHALLENGES:
|
| 436 |
+
return {"active": False, "message": "No active SQL session. Please initialize with 'db init'."}
|
| 437 |
+
|
| 438 |
+
challenge = ACTIVE_SQL_CHALLENGES[user_id]
|
| 439 |
+
|
| 440 |
+
db_path = sql_playground.get_db_path(user_id)
|
| 441 |
+
schema = {}
|
| 442 |
+
if os.path.exists(db_path):
|
| 443 |
+
import sqlite3
|
| 444 |
+
try:
|
| 445 |
+
conn = sqlite3.connect(db_path)
|
| 446 |
+
schema = sql_playground.get_db_schema(conn)
|
| 447 |
+
conn.close()
|
| 448 |
+
except Exception:
|
| 449 |
+
pass
|
| 450 |
+
|
| 451 |
+
completed = False
|
| 452 |
+
try:
|
| 453 |
+
res = client.table('challenge_history')\
|
| 454 |
+
.select('id')\
|
| 455 |
+
.eq('user_id', user_id)\
|
| 456 |
+
.eq('challenge_type', 'mysql')\
|
| 457 |
+
.eq('challenge_title', challenge.get("title", ""))\
|
| 458 |
+
.execute()
|
| 459 |
+
completed = len(res.data) > 0 if res.data else False
|
| 460 |
+
except Exception:
|
| 461 |
+
pass
|
| 462 |
+
|
| 463 |
+
score_record = db.get_user_score(client)
|
| 464 |
+
total_score = score_record["total_score"] if score_record else 0
|
| 465 |
+
|
| 466 |
+
return {
|
| 467 |
+
"active": True,
|
| 468 |
+
"title": challenge.get("title"),
|
| 469 |
+
"objective": challenge.get("objective"),
|
| 470 |
+
"points": challenge.get("points"),
|
| 471 |
+
"challenge_type": challenge.get("challenge_type"),
|
| 472 |
+
"schema": schema,
|
| 473 |
+
"completed": completed,
|
| 474 |
+
"total_score": total_score
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
@app.post("/api/challenges/mysql/query")
|
| 478 |
+
def run_mysql_query(req: SQLQueryRequest, client: Client = Depends(get_supabase_client)):
|
| 479 |
+
user_id = db.get_current_user_id(client)
|
| 480 |
+
if not user_id:
|
| 481 |
+
raise HTTPException(status_code=401, detail="Authentication failed")
|
| 482 |
+
|
| 483 |
+
if user_id not in ACTIVE_SQL_CHALLENGES:
|
| 484 |
+
raise HTTPException(status_code=400, detail="No active SQL session. Type 'db init' first.")
|
| 485 |
+
|
| 486 |
+
challenge = ACTIVE_SQL_CHALLENGES[user_id]
|
| 487 |
+
|
| 488 |
+
run_res = sql_playground.run_user_query(user_id, req.query, challenge)
|
| 489 |
+
|
| 490 |
+
if not run_res.get("success", False):
|
| 491 |
+
return {
|
| 492 |
+
"success": False,
|
| 493 |
+
"error": run_res.get("error", "Unknown database error")
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
is_correct = run_res.get("is_correct", False)
|
| 497 |
+
points_awarded = 0
|
| 498 |
+
completed_before = False
|
| 499 |
+
total_score = 0
|
| 500 |
+
|
| 501 |
+
if is_correct:
|
| 502 |
+
title = challenge.get("title", "SQL Challenge")
|
| 503 |
+
points = challenge.get("points", 150)
|
| 504 |
+
|
| 505 |
+
try:
|
| 506 |
+
hist_res = client.table('challenge_history')\
|
| 507 |
+
.select('id')\
|
| 508 |
+
.eq('user_id', user_id)\
|
| 509 |
+
.eq('challenge_type', 'mysql')\
|
| 510 |
+
.eq('challenge_title', title)\
|
| 511 |
+
.execute()
|
| 512 |
+
completed_before = len(hist_res.data) > 0 if hist_res.data else False
|
| 513 |
+
except Exception:
|
| 514 |
+
pass
|
| 515 |
+
|
| 516 |
+
if not completed_before:
|
| 517 |
+
db.increment_user_score(points, "mysql", client)
|
| 518 |
+
db.add_challenge_history("mysql", title, points, client)
|
| 519 |
+
points_awarded = points
|
| 520 |
+
|
| 521 |
+
score_record = db.get_user_score(client)
|
| 522 |
+
if score_record:
|
| 523 |
+
total_score = score_record["total_score"]
|
| 524 |
+
|
| 525 |
+
return {
|
| 526 |
+
"success": True,
|
| 527 |
+
"is_correct": is_correct,
|
| 528 |
+
"columns": run_res.get("columns", []),
|
| 529 |
+
"rows": run_res.get("rows", []),
|
| 530 |
+
"rows_affected": run_res.get("rows_affected", 0),
|
| 531 |
+
"schema": run_res.get("schema", {}),
|
| 532 |
+
"points_awarded": points_awarded,
|
| 533 |
+
"completed_before": completed_before,
|
| 534 |
+
"total_score": total_score
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
class CreateProblemRequest(BaseModel):
|
| 538 |
+
name: str
|
| 539 |
+
pattern: str
|
| 540 |
+
difficulty: str
|
| 541 |
+
reference_code: str
|
| 542 |
+
description: str = ""
|
| 543 |
+
|
| 544 |
+
class UpdateProblemRequest(BaseModel):
|
| 545 |
+
name: str
|
| 546 |
+
pattern: str
|
| 547 |
+
difficulty: str
|
| 548 |
+
reference_code: str
|
| 549 |
+
description: str = ""
|
| 550 |
+
|
| 551 |
+
@app.get("/api/explorer/problems")
|
| 552 |
+
def get_explorer_problems(client: Client = Depends(get_supabase_client)):
|
| 553 |
+
user_id = db.get_current_user_id(client)
|
| 554 |
+
if not user_id:
|
| 555 |
+
raise HTTPException(status_code=401, detail="Authentication failed")
|
| 556 |
+
try:
|
| 557 |
+
res = client.table('problems')\
|
| 558 |
+
.select('id, name, pattern, difficulty, reference_code, description, user_reviews(box_level, next_review, last_reviewed, times_correct, total_attempts)')\
|
| 559 |
+
.eq('user_id', user_id)\
|
| 560 |
+
.execute()
|
| 561 |
+
|
| 562 |
+
problems = res.data if res.data else []
|
| 563 |
+
for p in problems:
|
| 564 |
+
reviews = p.pop("user_reviews", [])
|
| 565 |
+
if reviews and isinstance(reviews, list) and len(reviews) > 0:
|
| 566 |
+
rev = reviews[0]
|
| 567 |
+
p["box_level"] = rev.get("box_level", 1)
|
| 568 |
+
p["next_review"] = rev.get("next_review")
|
| 569 |
+
p["last_reviewed"] = rev.get("last_reviewed")
|
| 570 |
+
p["times_correct"] = rev.get("times_correct", 0)
|
| 571 |
+
p["total_attempts"] = rev.get("total_attempts", 0)
|
| 572 |
+
else:
|
| 573 |
+
p["box_level"] = None
|
| 574 |
+
p["next_review"] = None
|
| 575 |
+
p["last_reviewed"] = None
|
| 576 |
+
p["times_correct"] = 0
|
| 577 |
+
p["total_attempts"] = 0
|
| 578 |
+
return problems
|
| 579 |
+
except Exception as e:
|
| 580 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 581 |
+
|
| 582 |
+
@app.post("/api/explorer/problems/create")
|
| 583 |
+
def create_explorer_problem(req: CreateProblemRequest, client: Client = Depends(get_supabase_client)):
|
| 584 |
+
user_id = db.get_current_user_id(client)
|
| 585 |
+
if not user_id:
|
| 586 |
+
raise HTTPException(status_code=401, detail="Authentication failed")
|
| 587 |
+
|
| 588 |
+
if not req.name.strip():
|
| 589 |
+
raise HTTPException(status_code=400, detail="Problem name cannot be empty")
|
| 590 |
+
|
| 591 |
+
existing = db.get_problem_by_name(req.name, client)
|
| 592 |
+
if existing:
|
| 593 |
+
raise HTTPException(status_code=400, detail=f"A problem named '{req.name}' already exists.")
|
| 594 |
+
|
| 595 |
+
try:
|
| 596 |
+
prob_data = {
|
| 597 |
+
"name": req.name.strip(),
|
| 598 |
+
"pattern": req.pattern.strip() or "General",
|
| 599 |
+
"difficulty": req.difficulty or "Medium",
|
| 600 |
+
"reference_code": req.reference_code,
|
| 601 |
+
"description": req.description
|
| 602 |
+
}
|
| 603 |
+
res_prob = db.insert_problem(prob_data, client)
|
| 604 |
+
if not res_prob or not res_prob.data:
|
| 605 |
+
raise HTTPException(status_code=500, detail="Failed to create problem record")
|
| 606 |
+
|
| 607 |
+
new_prob_id = res_prob.data[0]['id']
|
| 608 |
+
|
| 609 |
+
review_data = {
|
| 610 |
+
"problem_id": new_prob_id,
|
| 611 |
+
"box_level": 1,
|
| 612 |
+
"next_review": datetime.now(timezone.utc).isoformat(),
|
| 613 |
+
"times_correct": 0,
|
| 614 |
+
"total_attempts": 0
|
| 615 |
+
}
|
| 616 |
+
db.insert_review(review_data, client)
|
| 617 |
+
|
| 618 |
+
return {"success": True, "problem_id": new_prob_id}
|
| 619 |
+
except Exception as e:
|
| 620 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 621 |
+
|
| 622 |
+
@app.put("/api/explorer/problems/{problem_id}")
|
| 623 |
+
def update_explorer_problem(problem_id: str, req: UpdateProblemRequest, client: Client = Depends(get_supabase_client)):
|
| 624 |
+
user_id = db.get_current_user_id(client)
|
| 625 |
+
if not user_id:
|
| 626 |
+
raise HTTPException(status_code=401, detail="Authentication failed")
|
| 627 |
+
|
| 628 |
+
try:
|
| 629 |
+
update_data = {
|
| 630 |
+
"name": req.name.strip(),
|
| 631 |
+
"pattern": req.pattern.strip() or "General",
|
| 632 |
+
"difficulty": req.difficulty or "Medium",
|
| 633 |
+
"reference_code": req.reference_code,
|
| 634 |
+
"description": req.description
|
| 635 |
+
}
|
| 636 |
+
res = db.update_problem(problem_id, update_data, client)
|
| 637 |
+
if not res or not res.data:
|
| 638 |
+
raise HTTPException(status_code=500, detail="Failed to update problem record")
|
| 639 |
+
return {"success": True}
|
| 640 |
+
except Exception as e:
|
| 641 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 642 |
+
|
| 643 |
+
@app.get("/api/explorer/export")
|
| 644 |
+
def export_solutions_zip(client: Client = Depends(get_supabase_client)):
|
| 645 |
+
import io
|
| 646 |
+
import zipfile
|
| 647 |
+
from fastapi.responses import StreamingResponse
|
| 648 |
+
import re
|
| 649 |
+
|
| 650 |
+
user_id = db.get_current_user_id(client)
|
| 651 |
+
if not user_id:
|
| 652 |
+
raise HTTPException(status_code=401, detail="Authentication failed")
|
| 653 |
+
|
| 654 |
+
try:
|
| 655 |
+
res = client.table('problems')\
|
| 656 |
+
.select('name, pattern, difficulty, reference_code, description')\
|
| 657 |
+
.eq('user_id', user_id)\
|
| 658 |
+
.execute()
|
| 659 |
+
|
| 660 |
+
problems = res.data if res.data else []
|
| 661 |
+
|
| 662 |
+
zip_buffer = io.BytesIO()
|
| 663 |
+
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
|
| 664 |
+
for p in problems:
|
| 665 |
+
name = p.get("name", "Unnamed_Problem")
|
| 666 |
+
pattern = p.get("pattern", "General")
|
| 667 |
+
difficulty = p.get("difficulty", "Medium")
|
| 668 |
+
code = p.get("reference_code", "")
|
| 669 |
+
description = p.get("description", "")
|
| 670 |
+
|
| 671 |
+
safe_name = re.sub(r'[^\w\-]', '_', name)
|
| 672 |
+
filename = f"{safe_name}.py"
|
| 673 |
+
|
| 674 |
+
if not code or not code.strip():
|
| 675 |
+
desc_comment = f"\n# Description: {description}" if description else ""
|
| 676 |
+
code = f"# Problem: {name}\n# Pattern: {pattern}\n# Difficulty: {difficulty}{desc_comment}\n# Write your solution here!\n\ndef solve():\n pass\n"
|
| 677 |
+
else:
|
| 678 |
+
if description:
|
| 679 |
+
formatted_desc = "\n".join([f"# {line}" for line in description.splitlines()])
|
| 680 |
+
code = f"# Description:\n{formatted_desc}\n\n" + code
|
| 681 |
+
|
| 682 |
+
zip_file.writestr(filename, code)
|
| 683 |
+
|
| 684 |
+
zip_buffer.seek(0)
|
| 685 |
+
|
| 686 |
+
headers = {
|
| 687 |
+
"Content-Disposition": "attachment; filename=algospaced_solutions.zip"
|
| 688 |
+
}
|
| 689 |
+
return StreamingResponse(
|
| 690 |
+
zip_buffer,
|
| 691 |
+
media_type="application/zip",
|
| 692 |
+
headers=headers
|
| 693 |
+
)
|
| 694 |
+
except Exception as e:
|
| 695 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 696 |
+
|
| 697 |
+
# Serve static files from Vite frontend in production
|
| 698 |
+
from fastapi.staticfiles import StaticFiles
|
| 699 |
+
from fastapi.responses import FileResponse
|
| 700 |
+
|
| 701 |
+
frontend_dist = os.path.join(os.path.dirname(__file__), "algospaced-ui", "dist")
|
| 702 |
+
if os.path.exists(frontend_dist):
|
| 703 |
+
# Mount the assets directory (contains compiled js, css, images)
|
| 704 |
+
app.mount("/assets", StaticFiles(directory=os.path.join(frontend_dist, "assets")), name="assets")
|
| 705 |
+
|
| 706 |
+
@app.get("/favicon.ico", include_in_schema=False)
|
| 707 |
+
def serve_favicon():
|
| 708 |
+
fav = os.path.join(frontend_dist, "favicon.ico")
|
| 709 |
+
if os.path.exists(fav):
|
| 710 |
+
return FileResponse(fav)
|
| 711 |
+
raise HTTPException(status_code=404)
|
| 712 |
+
|
| 713 |
+
@app.get("/{fallback_path:path}")
|
| 714 |
+
def serve_frontend(fallback_path: str):
|
| 715 |
+
# Prevent intercepting API routes
|
| 716 |
+
if fallback_path.startswith("api/"):
|
| 717 |
+
raise HTTPException(status_code=404, detail="API endpoint not found")
|
| 718 |
+
|
| 719 |
+
index_file = os.path.join(frontend_dist, "index.html")
|
| 720 |
+
if os.path.exists(index_file):
|
| 721 |
+
return FileResponse(index_file)
|
| 722 |
+
raise HTTPException(status_code=404, detail="Frontend index.html not found")
|
| 723 |
+
|
| 724 |
+
|
| 725 |
+
|
| 726 |
+
|
| 727 |
+
|
requirements.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit>=1.35.0
|
| 2 |
+
supabase>=2.5.1
|
| 3 |
+
google-genai>=0.1.1
|
| 4 |
+
google-api-python-client>=2.131.0
|
| 5 |
+
google-auth-httplib2>=0.2.0
|
| 6 |
+
google-auth-oauthlib>=1.2.0
|
| 7 |
+
pandas>=2.2.2
|
| 8 |
+
streamlit-cookies-controller>=0.0.4
|
| 9 |
+
fastapi>=0.110.0
|
| 10 |
+
uvicorn>=0.29.0
|
| 11 |
+
toml>=0.10.2
|
| 12 |
+
|
run_app.bat
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@echo off
|
| 2 |
+
echo Starting AlgoSpaced application stack...
|
| 3 |
+
|
| 4 |
+
:: Start the FastAPI backend in a new window
|
| 5 |
+
echo Launching FastAPI server...
|
| 6 |
+
start "AlgoSpaced Backend" cmd /c ".\venv\Scripts\uvicorn main:app --reload --host 0.0.0.0 --port 8000"
|
| 7 |
+
|
| 8 |
+
:: Start the React Vite frontend in a new window
|
| 9 |
+
echo Launching React frontend...
|
| 10 |
+
start "AlgoSpaced Frontend" cmd /c "cd algospaced-ui && npm run dev"
|
| 11 |
+
|
| 12 |
+
:: Wait for 3 seconds
|
| 13 |
+
echo Waiting for servers to initialize...
|
| 14 |
+
timeout /t 3 /nobreak >nul
|
| 15 |
+
|
| 16 |
+
:: Launch default browser
|
| 17 |
+
echo Opening browser...
|
| 18 |
+
start http://localhost:5173
|
| 19 |
+
|
| 20 |
+
echo All services launched!
|
sandbox.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
import subprocess
|
| 3 |
+
import json
|
| 4 |
+
import tempfile
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
EXECUTION_TEMPLATE = """
|
| 8 |
+
import sys
|
| 9 |
+
import json
|
| 10 |
+
import traceback
|
| 11 |
+
|
| 12 |
+
# --- User Code Start ---
|
| 13 |
+
{user_code}
|
| 14 |
+
# --- User Code End ---
|
| 15 |
+
|
| 16 |
+
def run_tests():
|
| 17 |
+
test_cases = {test_cases_json}
|
| 18 |
+
results = []
|
| 19 |
+
|
| 20 |
+
# Locate entry point function
|
| 21 |
+
func = None
|
| 22 |
+
if 'solve' in globals() and callable(globals()['solve']):
|
| 23 |
+
func = globals()['solve']
|
| 24 |
+
else:
|
| 25 |
+
for name, obj in list(globals().items()):
|
| 26 |
+
if callable(obj) and not name.startswith('_') and name not in ('run_tests', 'sys', 'json', 'traceback', 'copy'):
|
| 27 |
+
func = obj
|
| 28 |
+
break
|
| 29 |
+
|
| 30 |
+
if not func:
|
| 31 |
+
print(json.dumps([ {{"status": "error", "error": "No callable function found in your code. Please define a function (e.g., 'def solve(...)')."}} ]))
|
| 32 |
+
return
|
| 33 |
+
|
| 34 |
+
for idx, tc in enumerate(test_cases):
|
| 35 |
+
inputs = tc.get("input", [])
|
| 36 |
+
expected = tc.get("expected")
|
| 37 |
+
if not isinstance(inputs, list):
|
| 38 |
+
inputs = [inputs]
|
| 39 |
+
try:
|
| 40 |
+
import copy
|
| 41 |
+
args = copy.deepcopy(inputs)
|
| 42 |
+
res = func(*args)
|
| 43 |
+
|
| 44 |
+
# Comparison
|
| 45 |
+
is_match = (res == expected)
|
| 46 |
+
results.append({{
|
| 47 |
+
"index": idx,
|
| 48 |
+
"status": "passed" if is_match else "failed",
|
| 49 |
+
"input": inputs,
|
| 50 |
+
"expected": expected,
|
| 51 |
+
"got": res
|
| 52 |
+
}})
|
| 53 |
+
except Exception as e:
|
| 54 |
+
results.append({{
|
| 55 |
+
"index": idx,
|
| 56 |
+
"status": "error",
|
| 57 |
+
"input": inputs,
|
| 58 |
+
"expected": expected,
|
| 59 |
+
"error": str(e),
|
| 60 |
+
"traceback": traceback.format_exc().splitlines()[-2:]
|
| 61 |
+
}})
|
| 62 |
+
|
| 63 |
+
print(json.dumps(results))
|
| 64 |
+
|
| 65 |
+
if __name__ == '__main__':
|
| 66 |
+
run_tests()
|
| 67 |
+
"""
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def run_python_sandbox(user_code: str, test_cases: list, timeout: float = 4.0) -> dict:
|
| 71 |
+
"""
|
| 72 |
+
Executes user code against test cases in an isolated sandbox.
|
| 73 |
+
Attempts Docker execution first, then falls back to restricted subprocess.
|
| 74 |
+
"""
|
| 75 |
+
test_cases_json = json.dumps(test_cases)
|
| 76 |
+
full_script = EXECUTION_TEMPLATE.format(
|
| 77 |
+
user_code=user_code,
|
| 78 |
+
test_cases_json=test_cases_json
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
# Try Docker execution
|
| 82 |
+
try:
|
| 83 |
+
# Run python:3.11-slim container with stdin, no network, 128MB memory, 0.5 CPU
|
| 84 |
+
# On Windows, we pipe the script directly to python via stdin to avoid path mounts
|
| 85 |
+
cmd = [
|
| 86 |
+
"docker", "run", "--rm", "-i",
|
| 87 |
+
"--network", "none",
|
| 88 |
+
"--memory", "128m",
|
| 89 |
+
"--cpus", "0.5",
|
| 90 |
+
"python:3.11-slim",
|
| 91 |
+
"python"
|
| 92 |
+
]
|
| 93 |
+
|
| 94 |
+
proc = subprocess.Popen(
|
| 95 |
+
cmd,
|
| 96 |
+
stdin=subprocess.PIPE,
|
| 97 |
+
stdout=subprocess.PIPE,
|
| 98 |
+
stderr=subprocess.PIPE,
|
| 99 |
+
text=True
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
stdout, stderr = proc.communicate(input=full_script, timeout=timeout)
|
| 103 |
+
|
| 104 |
+
if proc.returncode == 137:
|
| 105 |
+
return {
|
| 106 |
+
"success": False,
|
| 107 |
+
"error": "Memory limit exceeded (OOM) or container killed.",
|
| 108 |
+
"sandbox_type": "docker"
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
if proc.returncode != 0:
|
| 112 |
+
return {
|
| 113 |
+
"success": False,
|
| 114 |
+
"error": stderr or f"Execution failed with code {proc.returncode}",
|
| 115 |
+
"sandbox_type": "docker"
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
try:
|
| 119 |
+
results = json.loads(stdout.strip())
|
| 120 |
+
return {
|
| 121 |
+
"success": True,
|
| 122 |
+
"results": results,
|
| 123 |
+
"sandbox_type": "docker"
|
| 124 |
+
}
|
| 125 |
+
except json.JSONDecodeError:
|
| 126 |
+
return {
|
| 127 |
+
"success": False,
|
| 128 |
+
"error": f"Invalid output from execution: {stdout}\nErrors: {stderr}",
|
| 129 |
+
"sandbox_type": "docker"
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
except (subprocess.TimeoutExpired, TimeoutError):
|
| 133 |
+
# Clean up process
|
| 134 |
+
try:
|
| 135 |
+
proc.kill()
|
| 136 |
+
except Exception:
|
| 137 |
+
pass
|
| 138 |
+
return {
|
| 139 |
+
"success": False,
|
| 140 |
+
"error": f"Execution timed out. Code exceeded max runtime of {timeout}s.",
|
| 141 |
+
"sandbox_type": "docker"
|
| 142 |
+
}
|
| 143 |
+
except Exception as docker_err:
|
| 144 |
+
# Fall back to native local subprocess execution with limited environment
|
| 145 |
+
return run_local_fallback(full_script, timeout, str(docker_err))
|
| 146 |
+
|
| 147 |
+
def run_local_fallback(script_content: str, timeout: float, debug_msg: str) -> dict:
|
| 148 |
+
"""
|
| 149 |
+
Fallback execution using current python environment.
|
| 150 |
+
"""
|
| 151 |
+
try:
|
| 152 |
+
proc = subprocess.Popen(
|
| 153 |
+
[sys.executable],
|
| 154 |
+
stdin=subprocess.PIPE,
|
| 155 |
+
stdout=subprocess.PIPE,
|
| 156 |
+
stderr=subprocess.PIPE,
|
| 157 |
+
text=True
|
| 158 |
+
)
|
| 159 |
+
stdout, stderr = proc.communicate(input=script_content, timeout=timeout)
|
| 160 |
+
|
| 161 |
+
if proc.returncode != 0:
|
| 162 |
+
return {
|
| 163 |
+
"success": False,
|
| 164 |
+
"error": stderr or f"Local execution failed with code {proc.returncode}",
|
| 165 |
+
"sandbox_type": "local_fallback",
|
| 166 |
+
"sandbox_warning": f"Docker isolation unavailable: {debug_msg}"
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
try:
|
| 170 |
+
results = json.loads(stdout.strip())
|
| 171 |
+
return {
|
| 172 |
+
"success": True,
|
| 173 |
+
"results": results,
|
| 174 |
+
"sandbox_type": "local_fallback",
|
| 175 |
+
"sandbox_warning": f"Docker isolation unavailable: {debug_msg}"
|
| 176 |
+
}
|
| 177 |
+
except json.JSONDecodeError:
|
| 178 |
+
return {
|
| 179 |
+
"success": False,
|
| 180 |
+
"error": f"Invalid output from local runner: {stdout}",
|
| 181 |
+
"sandbox_type": "local_fallback"
|
| 182 |
+
}
|
| 183 |
+
except subprocess.TimeoutExpired:
|
| 184 |
+
try:
|
| 185 |
+
proc.kill()
|
| 186 |
+
except Exception:
|
| 187 |
+
pass
|
| 188 |
+
return {
|
| 189 |
+
"success": False,
|
| 190 |
+
"error": f"Local execution timed out. Code exceeded {timeout}s.",
|
| 191 |
+
"sandbox_type": "local_fallback"
|
| 192 |
+
}
|
| 193 |
+
except Exception as e:
|
| 194 |
+
return {
|
| 195 |
+
"success": False,
|
| 196 |
+
"error": f"Failed to execute local fallback runner: {str(e)}",
|
| 197 |
+
"sandbox_type": "none"
|
| 198 |
+
}
|
schema.sql
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- Drop existing tables if they exist to prevent schema mismatch
|
| 2 |
+
DROP TABLE IF EXISTS user_reviews CASCADE;
|
| 3 |
+
DROP TABLE IF EXISTS user_streaks CASCADE;
|
| 4 |
+
DROP TABLE IF EXISTS problems CASCADE;
|
| 5 |
+
|
| 6 |
+
-- Enable UUID extension
|
| 7 |
+
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
| 8 |
+
|
| 9 |
+
-- Table: problems
|
| 10 |
+
CREATE TABLE problems (
|
| 11 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 12 |
+
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
| 13 |
+
name VARCHAR(255) NOT NULL,
|
| 14 |
+
problem_number INTEGER, -- Tracked for Journey Path
|
| 15 |
+
pattern VARCHAR(100) NOT NULL,
|
| 16 |
+
difficulty VARCHAR(20) NOT NULL DEFAULT 'Medium' CHECK (difficulty IN ('Easy', 'Medium', 'Hard')),
|
| 17 |
+
google_doc_url TEXT,
|
| 18 |
+
google_doc_id VARCHAR(100), -- Tracks file
|
| 19 |
+
optimal_time VARCHAR(50) NOT NULL DEFAULT 'O(N)',
|
| 20 |
+
optimal_space VARCHAR(50) NOT NULL DEFAULT 'O(N)',
|
| 21 |
+
description TEXT,
|
| 22 |
+
reference_code TEXT, -- Stores reference code synced from Doc
|
| 23 |
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
| 24 |
+
CONSTRAINT unique_user_problem UNIQUE (user_id, name)
|
| 25 |
+
);
|
| 26 |
+
|
| 27 |
+
-- Table: user_reviews
|
| 28 |
+
CREATE TABLE user_reviews (
|
| 29 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 30 |
+
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
| 31 |
+
problem_id UUID NOT NULL REFERENCES problems(id) ON DELETE CASCADE,
|
| 32 |
+
box_level INTEGER NOT NULL DEFAULT 1 CHECK (box_level BETWEEN 1 AND 5),
|
| 33 |
+
last_reviewed TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
| 34 |
+
next_review TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
| 35 |
+
times_correct INTEGER NOT NULL DEFAULT 0,
|
| 36 |
+
total_attempts INTEGER NOT NULL DEFAULT 0,
|
| 37 |
+
CONSTRAINT unique_user_problem_review UNIQUE (user_id, problem_id)
|
| 38 |
+
);
|
| 39 |
+
|
| 40 |
+
-- Table: user_streaks
|
| 41 |
+
CREATE TABLE user_streaks (
|
| 42 |
+
user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
| 43 |
+
current_streak INTEGER NOT NULL DEFAULT 0 CHECK (current_streak >= 0),
|
| 44 |
+
longest_streak INTEGER NOT NULL DEFAULT 0 CHECK (longest_streak >= 0),
|
| 45 |
+
last_active_date DATE,
|
| 46 |
+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
| 47 |
+
);
|
| 48 |
+
|
| 49 |
+
-- Enable RLS on all tables
|
| 50 |
+
ALTER TABLE problems ENABLE ROW LEVEL SECURITY;
|
| 51 |
+
ALTER TABLE user_reviews ENABLE ROW LEVEL SECURITY;
|
| 52 |
+
ALTER TABLE user_streaks ENABLE ROW LEVEL SECURITY;
|
| 53 |
+
|
| 54 |
+
-- RLS Policies for problems
|
| 55 |
+
CREATE POLICY select_problems ON problems FOR SELECT TO authenticated USING (auth.uid() = user_id);
|
| 56 |
+
CREATE POLICY insert_problems ON problems FOR INSERT TO authenticated WITH CHECK (auth.uid() = user_id);
|
| 57 |
+
CREATE POLICY update_problems ON problems FOR UPDATE TO authenticated USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id);
|
| 58 |
+
CREATE POLICY delete_problems ON problems FOR DELETE TO authenticated USING (auth.uid() = user_id);
|
| 59 |
+
|
| 60 |
+
-- RLS Policies for user_reviews
|
| 61 |
+
CREATE POLICY select_reviews ON user_reviews FOR SELECT TO authenticated USING (auth.uid() = user_id);
|
| 62 |
+
CREATE POLICY insert_reviews ON user_reviews FOR INSERT TO authenticated WITH CHECK (auth.uid() = user_id);
|
| 63 |
+
CREATE POLICY update_reviews ON user_reviews FOR UPDATE TO authenticated USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id);
|
| 64 |
+
CREATE POLICY delete_reviews ON user_reviews FOR DELETE TO authenticated USING (auth.uid() = user_id);
|
| 65 |
+
|
| 66 |
+
-- RLS Policies for user_streaks
|
| 67 |
+
CREATE POLICY select_streaks ON user_streaks FOR SELECT TO authenticated USING (auth.uid() = user_id);
|
| 68 |
+
CREATE POLICY insert_streaks ON user_streaks FOR INSERT TO authenticated WITH CHECK (auth.uid() = user_id);
|
| 69 |
+
CREATE POLICY update_streaks ON user_streaks FOR UPDATE TO authenticated USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id);
|
| 70 |
+
CREATE POLICY delete_streaks ON user_streaks FOR DELETE TO authenticated USING (auth.uid() = user_id);
|
| 71 |
+
|
| 72 |
+
-- Table: user_journey_progress
|
| 73 |
+
CREATE TABLE user_journey_progress (
|
| 74 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 75 |
+
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
| 76 |
+
problem_number INTEGER NOT NULL,
|
| 77 |
+
viewed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
| 78 |
+
CONSTRAINT unique_user_journey_problem UNIQUE (user_id, problem_number)
|
| 79 |
+
);
|
| 80 |
+
|
| 81 |
+
-- Enable RLS on new table
|
| 82 |
+
ALTER TABLE user_journey_progress ENABLE ROW LEVEL SECURITY;
|
| 83 |
+
|
| 84 |
+
-- RLS Policies for user_journey_progress
|
| 85 |
+
CREATE POLICY select_journey ON user_journey_progress FOR SELECT TO authenticated USING (auth.uid() = user_id);
|
| 86 |
+
CREATE POLICY insert_journey ON user_journey_progress FOR INSERT TO authenticated WITH CHECK (auth.uid() = user_id);
|
| 87 |
+
CREATE POLICY update_journey ON user_journey_progress FOR UPDATE TO authenticated USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id);
|
| 88 |
+
CREATE POLICY delete_journey ON user_journey_progress FOR DELETE TO authenticated USING (auth.uid() = user_id);
|
| 89 |
+
|
| 90 |
+
-- Table: user_scores
|
| 91 |
+
CREATE TABLE IF NOT EXISTS user_scores (
|
| 92 |
+
user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
| 93 |
+
total_score INTEGER DEFAULT 0 CHECK (total_score >= 0),
|
| 94 |
+
python_challenges_completed INTEGER DEFAULT 0,
|
| 95 |
+
sql_challenges_completed INTEGER DEFAULT 0,
|
| 96 |
+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
| 97 |
+
);
|
| 98 |
+
|
| 99 |
+
-- Table: challenge_history
|
| 100 |
+
CREATE TABLE IF NOT EXISTS challenge_history (
|
| 101 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 102 |
+
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
| 103 |
+
challenge_type VARCHAR(20) NOT NULL CHECK (challenge_type IN ('python', 'mysql')),
|
| 104 |
+
challenge_title VARCHAR(255) NOT NULL,
|
| 105 |
+
points_awarded INTEGER NOT NULL DEFAULT 0,
|
| 106 |
+
completed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
| 107 |
+
);
|
| 108 |
+
|
| 109 |
+
-- Enable RLS on new tables
|
| 110 |
+
ALTER TABLE user_scores ENABLE ROW LEVEL SECURITY;
|
| 111 |
+
ALTER TABLE challenge_history ENABLE ROW LEVEL SECURITY;
|
| 112 |
+
|
| 113 |
+
-- RLS Policies for user_scores
|
| 114 |
+
CREATE POLICY select_scores ON user_scores FOR SELECT TO authenticated USING (auth.uid() = user_id);
|
| 115 |
+
CREATE POLICY insert_scores ON user_scores FOR INSERT TO authenticated WITH CHECK (auth.uid() = user_id);
|
| 116 |
+
CREATE POLICY update_scores ON user_scores FOR UPDATE TO authenticated USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id);
|
| 117 |
+
CREATE POLICY delete_scores ON user_scores FOR DELETE TO authenticated USING (auth.uid() = user_id);
|
| 118 |
+
|
| 119 |
+
-- RLS Policies for challenge_history
|
| 120 |
+
CREATE POLICY select_history ON challenge_history FOR SELECT TO authenticated USING (auth.uid() = user_id);
|
| 121 |
+
CREATE POLICY insert_history ON challenge_history FOR INSERT TO authenticated WITH CHECK (auth.uid() = user_id);
|
| 122 |
+
CREATE POLICY update_history ON challenge_history FOR UPDATE TO authenticated USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id);
|
| 123 |
+
CREATE POLICY delete_history ON challenge_history FOR DELETE TO authenticated USING (auth.uid() = user_id);
|
| 124 |
+
|
| 125 |
+
|
scratch/test_explorer_upgrades.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import io
|
| 4 |
+
import zipfile
|
| 5 |
+
from fastapi.testclient import TestClient
|
| 6 |
+
|
| 7 |
+
# Add workspace root to python path
|
| 8 |
+
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 9 |
+
|
| 10 |
+
from main import app, get_supabase_client
|
| 11 |
+
|
| 12 |
+
# Define mock classes to intercept Supabase client interactions
|
| 13 |
+
class MockResponse:
|
| 14 |
+
def __init__(self, data):
|
| 15 |
+
self.data = data
|
| 16 |
+
|
| 17 |
+
class MockQueryBuilder:
|
| 18 |
+
def __init__(self, table_name, mock_client):
|
| 19 |
+
self.table_name = table_name
|
| 20 |
+
self.mock_client = mock_client
|
| 21 |
+
self.filters = {}
|
| 22 |
+
|
| 23 |
+
def select(self, columns):
|
| 24 |
+
return self
|
| 25 |
+
|
| 26 |
+
def eq(self, column, value):
|
| 27 |
+
self.filters[column] = value
|
| 28 |
+
return self
|
| 29 |
+
|
| 30 |
+
def insert(self, data):
|
| 31 |
+
if self.table_name == 'problems':
|
| 32 |
+
if not isinstance(data, list):
|
| 33 |
+
data = [data]
|
| 34 |
+
inserted = []
|
| 35 |
+
for item in data:
|
| 36 |
+
item_copy = item.copy()
|
| 37 |
+
if 'id' not in item_copy:
|
| 38 |
+
item_copy['id'] = f"mock-prob-id-{len(self.mock_client.problems) + 1}"
|
| 39 |
+
self.mock_client.problems.append(item_copy)
|
| 40 |
+
inserted.append(item_copy)
|
| 41 |
+
self.last_result = inserted
|
| 42 |
+
elif self.table_name == 'user_reviews':
|
| 43 |
+
if not isinstance(data, list):
|
| 44 |
+
data = [data]
|
| 45 |
+
inserted = []
|
| 46 |
+
for item in data:
|
| 47 |
+
item_copy = item.copy()
|
| 48 |
+
if 'id' not in item_copy:
|
| 49 |
+
item_copy['id'] = f"mock-rev-id-{len(self.mock_client.reviews) + 1}"
|
| 50 |
+
self.mock_client.reviews.append(item_copy)
|
| 51 |
+
inserted.append(item_copy)
|
| 52 |
+
self.last_result = inserted
|
| 53 |
+
return self
|
| 54 |
+
|
| 55 |
+
def update(self, data):
|
| 56 |
+
self.update_data = data
|
| 57 |
+
return self
|
| 58 |
+
|
| 59 |
+
def execute(self):
|
| 60 |
+
if hasattr(self, 'last_result'):
|
| 61 |
+
return MockResponse(self.last_result)
|
| 62 |
+
|
| 63 |
+
if self.table_name == 'problems':
|
| 64 |
+
name_filter = self.filters.get('name')
|
| 65 |
+
id_filter = self.filters.get('id')
|
| 66 |
+
|
| 67 |
+
# If update was called
|
| 68 |
+
if hasattr(self, 'update_data'):
|
| 69 |
+
updated = []
|
| 70 |
+
for p in self.mock_client.problems:
|
| 71 |
+
if id_filter and p.get('id') == id_filter:
|
| 72 |
+
p.update(self.update_data)
|
| 73 |
+
updated.append(p)
|
| 74 |
+
return MockResponse(updated)
|
| 75 |
+
|
| 76 |
+
matches = []
|
| 77 |
+
for p in self.mock_client.problems:
|
| 78 |
+
if name_filter and p.get('name') != name_filter:
|
| 79 |
+
continue
|
| 80 |
+
if id_filter and p.get('id') != id_filter:
|
| 81 |
+
continue
|
| 82 |
+
p_copy = p.copy()
|
| 83 |
+
p_reviews = [r for r in self.mock_client.reviews if r.get('problem_id') == p.get('id')]
|
| 84 |
+
p_copy['user_reviews'] = p_reviews
|
| 85 |
+
matches.append(p_copy)
|
| 86 |
+
return MockResponse(matches)
|
| 87 |
+
|
| 88 |
+
elif self.table_name == 'user_reviews':
|
| 89 |
+
return MockResponse(self.mock_client.reviews)
|
| 90 |
+
|
| 91 |
+
return MockResponse([])
|
| 92 |
+
|
| 93 |
+
class MockSupabaseClient:
|
| 94 |
+
def __init__(self):
|
| 95 |
+
self.problems = []
|
| 96 |
+
self.reviews = []
|
| 97 |
+
self.current_user_id = "mock_user_123"
|
| 98 |
+
self.auth = self
|
| 99 |
+
|
| 100 |
+
def get_user(self, token):
|
| 101 |
+
class MockUser:
|
| 102 |
+
id = "mock_user_123"
|
| 103 |
+
class MockUserRes:
|
| 104 |
+
user = MockUser()
|
| 105 |
+
return MockUserRes()
|
| 106 |
+
|
| 107 |
+
def table(self, table_name):
|
| 108 |
+
return MockQueryBuilder(table_name, self)
|
| 109 |
+
|
| 110 |
+
# Create a shared mock client state
|
| 111 |
+
shared_mock_client = MockSupabaseClient()
|
| 112 |
+
|
| 113 |
+
# Override FastAPI dependency
|
| 114 |
+
def mock_get_supabase_client():
|
| 115 |
+
return shared_mock_client
|
| 116 |
+
|
| 117 |
+
app.dependency_overrides[get_supabase_client] = mock_get_supabase_client
|
| 118 |
+
|
| 119 |
+
client = TestClient(app)
|
| 120 |
+
|
| 121 |
+
def test_explorer_endpoints():
|
| 122 |
+
print("=== Running Explorer REST API Tests ===")
|
| 123 |
+
headers = {"Authorization": "Bearer mock-token-123"}
|
| 124 |
+
|
| 125 |
+
# 1. Create Problem 1 (Two Sum)
|
| 126 |
+
print("\n1. Testing POST /api/explorer/problems/create (Two Sum)")
|
| 127 |
+
create_payload = {
|
| 128 |
+
"name": "Two Sum",
|
| 129 |
+
"pattern": "Two Pointers",
|
| 130 |
+
"difficulty": "Easy",
|
| 131 |
+
"reference_code": "def twoSum(nums, target):\n pass",
|
| 132 |
+
"description": "Find two numbers in the array that sum to target."
|
| 133 |
+
}
|
| 134 |
+
res = client.post("/api/explorer/problems/create", json=create_payload, headers=headers)
|
| 135 |
+
print("Response status:", res.status_code)
|
| 136 |
+
print("Response JSON:", res.json())
|
| 137 |
+
assert res.status_code == 200
|
| 138 |
+
assert res.json()["success"] is True
|
| 139 |
+
prob1_id = res.json()["problem_id"]
|
| 140 |
+
|
| 141 |
+
# Verify review was created
|
| 142 |
+
assert len(shared_mock_client.reviews) == 1
|
| 143 |
+
assert shared_mock_client.reviews[0]["problem_id"] == prob1_id
|
| 144 |
+
assert shared_mock_client.reviews[0]["box_level"] == 1
|
| 145 |
+
|
| 146 |
+
# 2. Try to create duplicate problem
|
| 147 |
+
print("\n2. Testing duplicate prevention")
|
| 148 |
+
res_dup = client.post("/api/explorer/problems/create", json=create_payload, headers=headers)
|
| 149 |
+
print("Response status:", res_dup.status_code)
|
| 150 |
+
print("Response JSON:", res_dup.json())
|
| 151 |
+
assert res_dup.status_code == 400
|
| 152 |
+
assert "already exists" in res_dup.json()["detail"]
|
| 153 |
+
|
| 154 |
+
# 3. Create Problem 2 (Three Sum)
|
| 155 |
+
print("\n3. Testing POST /api/explorer/problems/create (Three Sum)")
|
| 156 |
+
create_payload_2 = {
|
| 157 |
+
"name": "Three Sum",
|
| 158 |
+
"pattern": "Two Pointers",
|
| 159 |
+
"difficulty": "Medium",
|
| 160 |
+
"reference_code": "def threeSum(nums):\n pass",
|
| 161 |
+
"description": "Find all unique triplets that sum to zero."
|
| 162 |
+
}
|
| 163 |
+
res2 = client.post("/api/explorer/problems/create", json=create_payload_2, headers=headers)
|
| 164 |
+
assert res2.status_code == 200
|
| 165 |
+
prob2_id = res2.json()["problem_id"]
|
| 166 |
+
|
| 167 |
+
# 4. List Problems
|
| 168 |
+
print("\n4. Testing GET /api/explorer/problems")
|
| 169 |
+
res_list = client.get("/api/explorer/problems", headers=headers)
|
| 170 |
+
print("Response status:", res_list.status_code)
|
| 171 |
+
problems_list = res_list.json()
|
| 172 |
+
print("List count:", len(problems_list))
|
| 173 |
+
for p in problems_list:
|
| 174 |
+
print(f" - Problem: {p['name']}, Box: {p['box_level']}, Description: '{p['description']}', Code length: {len(p['reference_code'])}")
|
| 175 |
+
assert res_list.status_code == 200
|
| 176 |
+
assert len(problems_list) == 2
|
| 177 |
+
assert problems_list[0]["box_level"] == 1
|
| 178 |
+
assert problems_list[0]["description"] == "Find two numbers in the array that sum to target."
|
| 179 |
+
|
| 180 |
+
# 5. Update reference code for Two Sum
|
| 181 |
+
print("\n5. Testing PUT /api/explorer/problems/{id}")
|
| 182 |
+
new_code = "def twoSum(nums, target):\n # Optimized solution\n seen = {}\n for i, num in enumerate(nums):\n diff = target - num\n if diff in seen:\n return [seen[diff], i]\n seen[num] = i\n return []"
|
| 183 |
+
update_payload = {
|
| 184 |
+
"name": "Two Sum",
|
| 185 |
+
"pattern": "Two Pointers",
|
| 186 |
+
"difficulty": "Easy",
|
| 187 |
+
"reference_code": new_code,
|
| 188 |
+
"description": "Find two numbers in the array that sum to target (Optimized)."
|
| 189 |
+
}
|
| 190 |
+
res_update = client.put(f"/api/explorer/problems/{prob1_id}", json=update_payload, headers=headers)
|
| 191 |
+
print("Response status:", res_update.status_code)
|
| 192 |
+
print("Response JSON:", res_update.json())
|
| 193 |
+
assert res_update.status_code == 200
|
| 194 |
+
assert res_update.json()["success"] is True
|
| 195 |
+
|
| 196 |
+
# Verify update in listing
|
| 197 |
+
res_list2 = client.get("/api/explorer/problems", headers=headers)
|
| 198 |
+
problems_list2 = res_list2.json()
|
| 199 |
+
two_sum_updated = next(p for p in problems_list2 if p["id"] == prob1_id)
|
| 200 |
+
print("Updated Two Sum code length:", len(two_sum_updated["reference_code"]))
|
| 201 |
+
print("Updated Two Sum description:", two_sum_updated["description"])
|
| 202 |
+
assert "Optimized solution" in two_sum_updated["reference_code"]
|
| 203 |
+
assert "Optimized" in two_sum_updated["description"]
|
| 204 |
+
|
| 205 |
+
# 6. Export ZIP of solutions
|
| 206 |
+
print("\n6. Testing GET /api/explorer/export (ZIP download)")
|
| 207 |
+
res_export = client.get("/api/explorer/export", headers=headers)
|
| 208 |
+
print("Response status:", res_export.status_code)
|
| 209 |
+
print("Content-Type:", res_export.headers.get("content-type"))
|
| 210 |
+
assert res_export.status_code == 200
|
| 211 |
+
assert res_export.headers.get("content-type") == "application/zip"
|
| 212 |
+
|
| 213 |
+
# Validate Zip structure
|
| 214 |
+
zip_bytes = io.BytesIO(res_export.content)
|
| 215 |
+
with zipfile.ZipFile(zip_bytes, "r") as zf:
|
| 216 |
+
namelist = zf.namelist()
|
| 217 |
+
print("Files in ZIP:", namelist)
|
| 218 |
+
assert "Two_Sum.py" in namelist
|
| 219 |
+
assert "Three_Sum.py" in namelist
|
| 220 |
+
|
| 221 |
+
# Read content from ZIP
|
| 222 |
+
two_sum_zip_code = zf.read("Two_Sum.py").decode("utf-8")
|
| 223 |
+
print("Two_Sum.py contents in ZIP:")
|
| 224 |
+
print(two_sum_zip_code)
|
| 225 |
+
assert "Optimized solution" in two_sum_zip_code
|
| 226 |
+
assert "# Description:" in two_sum_zip_code
|
| 227 |
+
assert "Find two numbers in the array that sum to target" in two_sum_zip_code
|
| 228 |
+
|
| 229 |
+
print("\n=== ALL TESTS PASSED SUCCESSFULLY ===")
|
| 230 |
+
|
| 231 |
+
if __name__ == "__main__":
|
| 232 |
+
test_explorer_endpoints()
|
scratch/test_sandbox_sql.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
|
| 4 |
+
# Add root folder to sys.path so we can import sandbox and sql_playground
|
| 5 |
+
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 6 |
+
|
| 7 |
+
import sandbox
|
| 8 |
+
import sql_playground
|
| 9 |
+
|
| 10 |
+
def test_python_sandbox():
|
| 11 |
+
print("--- Testing Python Sandbox ---")
|
| 12 |
+
|
| 13 |
+
# 1. Correct Submission Test
|
| 14 |
+
correct_code = "def solve(a, b):\n return a + b"
|
| 15 |
+
test_cases = [
|
| 16 |
+
{"input": [1, 2], "expected": 3},
|
| 17 |
+
{"input": [10, -5], "expected": 5}
|
| 18 |
+
]
|
| 19 |
+
res_correct = sandbox.run_python_sandbox(correct_code, test_cases)
|
| 20 |
+
print("Correct Code Result:")
|
| 21 |
+
print("Success:", res_correct.get("success"))
|
| 22 |
+
print("Sandbox Type:", res_correct.get("sandbox_type"))
|
| 23 |
+
if res_correct.get("success"):
|
| 24 |
+
for r in res_correct["results"]:
|
| 25 |
+
print(f" Test {r['index'] + 1}: status={r['status']}, got={r['got']}")
|
| 26 |
+
|
| 27 |
+
# 2. Logic Failure Test
|
| 28 |
+
failed_code = "def solve(a, b):\n return a - b"
|
| 29 |
+
res_failed = sandbox.run_python_sandbox(failed_code, test_cases)
|
| 30 |
+
print("\nFailed Code Result:")
|
| 31 |
+
print("Success:", res_failed.get("success"))
|
| 32 |
+
if res_failed.get("success"):
|
| 33 |
+
for r in res_failed["results"]:
|
| 34 |
+
print(f" Test {r['index'] + 1}: status={r['status']}, got={r['got']}, expected={r['expected']}")
|
| 35 |
+
|
| 36 |
+
def test_sql_playground():
|
| 37 |
+
print("\n--- Testing SQL Playground ---")
|
| 38 |
+
user_id = "test_developer_123"
|
| 39 |
+
|
| 40 |
+
challenge = {
|
| 41 |
+
"title": "High Earners",
|
| 42 |
+
"objective": "Find employees with salary greater than 85000",
|
| 43 |
+
"ddl": "CREATE TABLE employees (id INTEGER PRIMARY KEY, name TEXT, department TEXT, salary INTEGER);",
|
| 44 |
+
"inserts": "INSERT INTO employees VALUES (1, 'Alice', 'Engineering', 95000); INSERT INTO employees VALUES (2, 'Bob', 'HR', 75000); INSERT INTO employees VALUES (3, 'Charlie', 'Engineering', 100000);",
|
| 45 |
+
"validation_query": "SELECT name FROM employees WHERE salary > 85000;",
|
| 46 |
+
"challenge_type": "SELECT",
|
| 47 |
+
"points": 150
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
# 1. Init DB
|
| 51 |
+
init_res = sql_playground.init_user_db(user_id, challenge)
|
| 52 |
+
print("DB Init Result:", init_res)
|
| 53 |
+
|
| 54 |
+
# 2. Run correct SELECT query
|
| 55 |
+
correct_query = "SELECT name FROM employees WHERE salary > 85000"
|
| 56 |
+
res_correct = sql_playground.run_user_query(user_id, correct_query, challenge)
|
| 57 |
+
print("\nCorrect Query Result:")
|
| 58 |
+
print("Success:", res_correct.get("success"))
|
| 59 |
+
print("Is Correct:", res_correct.get("is_correct"))
|
| 60 |
+
print("Columns:", res_correct.get("columns"))
|
| 61 |
+
print("Rows:", res_correct.get("rows"))
|
| 62 |
+
|
| 63 |
+
# 3. Run incorrect SELECT query
|
| 64 |
+
incorrect_query = "SELECT name FROM employees"
|
| 65 |
+
res_incorrect = sql_playground.run_user_query(user_id, incorrect_query, challenge)
|
| 66 |
+
print("\nIncorrect Query Result:")
|
| 67 |
+
print("Success:", res_incorrect.get("success"))
|
| 68 |
+
print("Is Correct:", res_incorrect.get("is_correct"))
|
| 69 |
+
|
| 70 |
+
# 4. Test safety check
|
| 71 |
+
unsafe_query = "SELECT name FROM employees; DROP TABLE employees;"
|
| 72 |
+
res_unsafe = sql_playground.run_user_query(user_id, unsafe_query, challenge)
|
| 73 |
+
print("\nUnsafe Query Result:")
|
| 74 |
+
print("Success:", res_unsafe.get("success"))
|
| 75 |
+
print("Error:", res_unsafe.get("error"))
|
| 76 |
+
|
| 77 |
+
if __name__ == '__main__':
|
| 78 |
+
test_python_sandbox()
|
| 79 |
+
test_sql_playground()
|
sql_playground.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sqlite3
|
| 3 |
+
import re
|
| 4 |
+
import json
|
| 5 |
+
|
| 6 |
+
DB_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "scratch")
|
| 7 |
+
|
| 8 |
+
def get_db_path(user_id: str, suffix: str = "") -> str:
|
| 9 |
+
"""Returns the path to the user's SQLite session database."""
|
| 10 |
+
# Ensure scratch dir exists
|
| 11 |
+
os.makedirs(DB_DIR, exist_ok=True)
|
| 12 |
+
clean_id = "".join(c for c in user_id if c.isalnum() or c in ("-", "_"))
|
| 13 |
+
return os.path.join(DB_DIR, f"db_{clean_id}{suffix}.sqlite")
|
| 14 |
+
|
| 15 |
+
def is_query_safe(sql: str) -> tuple[bool, str]:
|
| 16 |
+
"""
|
| 17 |
+
Checks if a query is safe to execute.
|
| 18 |
+
Blocks command chaining, ATTACH, PRAGMA, and database administration commands.
|
| 19 |
+
"""
|
| 20 |
+
cleaned = sql.strip().upper()
|
| 21 |
+
|
| 22 |
+
# 1. Block command chaining (multiple queries separated by semicolon)
|
| 23 |
+
# Strip trailing semicolons first, then check if any semicolon remains
|
| 24 |
+
temp = cleaned.rstrip(';')
|
| 25 |
+
if ';' in temp:
|
| 26 |
+
return False, "Query chaining (using ';') is disabled for security."
|
| 27 |
+
|
| 28 |
+
# 2. Block file attachment and direct system configurations
|
| 29 |
+
blocked_keywords = [
|
| 30 |
+
r"\bATTACH\b", r"\bDETACH\b", r"\bPRAGMA\b", r"\bLOAD_EXTENSION\b",
|
| 31 |
+
r"\bSHUTDOWN\b", r"\bGRANT\b", r"\bREVOKE\b"
|
| 32 |
+
]
|
| 33 |
+
for pattern in blocked_keywords:
|
| 34 |
+
if re.search(pattern, cleaned):
|
| 35 |
+
return False, f"SQL command blocked for security: contains restricted keyword."
|
| 36 |
+
|
| 37 |
+
return True, ""
|
| 38 |
+
|
| 39 |
+
def get_db_schema(conn: sqlite3.Connection) -> dict:
|
| 40 |
+
"""Extracts column definitions for all tables in the database."""
|
| 41 |
+
cursor = conn.cursor()
|
| 42 |
+
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';")
|
| 43 |
+
tables = [row[0] for row in cursor.fetchall()]
|
| 44 |
+
|
| 45 |
+
schema = {}
|
| 46 |
+
for table in tables:
|
| 47 |
+
cursor.execute(f"PRAGMA table_info({table});")
|
| 48 |
+
# PRAGMA returns: (cid, name, type, notnull, dflt_value, pk)
|
| 49 |
+
columns = [{"name": r[1], "type": r[2]} for r in cursor.fetchall()]
|
| 50 |
+
schema[table] = columns
|
| 51 |
+
return schema
|
| 52 |
+
|
| 53 |
+
def init_user_db(user_id: str, challenge: dict) -> dict:
|
| 54 |
+
"""
|
| 55 |
+
Initializes a fresh session database for the user with DDL and mock data.
|
| 56 |
+
"""
|
| 57 |
+
db_path = get_db_path(user_id)
|
| 58 |
+
|
| 59 |
+
# Remove existing db if it exists
|
| 60 |
+
if os.path.exists(db_path):
|
| 61 |
+
try:
|
| 62 |
+
os.remove(db_path)
|
| 63 |
+
except Exception as e:
|
| 64 |
+
return {"success": False, "error": f"Failed to reset database session: {str(e)}"}
|
| 65 |
+
|
| 66 |
+
conn = None
|
| 67 |
+
try:
|
| 68 |
+
conn = sqlite3.connect(db_path)
|
| 69 |
+
cursor = conn.cursor()
|
| 70 |
+
|
| 71 |
+
# Execute DDL
|
| 72 |
+
ddl = challenge.get("ddl", "")
|
| 73 |
+
# Split DDL by semicolon to run statements sequentially
|
| 74 |
+
for statement in ddl.split(';'):
|
| 75 |
+
stmt = statement.strip()
|
| 76 |
+
if stmt:
|
| 77 |
+
cursor.execute(stmt)
|
| 78 |
+
|
| 79 |
+
# Execute Mock Data INSERTS
|
| 80 |
+
inserts = challenge.get("inserts", "")
|
| 81 |
+
for statement in inserts.split(';'):
|
| 82 |
+
stmt = statement.strip()
|
| 83 |
+
if stmt:
|
| 84 |
+
cursor.execute(stmt)
|
| 85 |
+
|
| 86 |
+
conn.commit()
|
| 87 |
+
|
| 88 |
+
# Extract schema for frontend visualization
|
| 89 |
+
schema = get_db_schema(conn)
|
| 90 |
+
return {
|
| 91 |
+
"success": True,
|
| 92 |
+
"schema": schema,
|
| 93 |
+
"message": "Database initialized successfully."
|
| 94 |
+
}
|
| 95 |
+
except Exception as e:
|
| 96 |
+
return {"success": False, "error": f"Database initialization failed: {str(e)}"}
|
| 97 |
+
finally:
|
| 98 |
+
if conn:
|
| 99 |
+
conn.close()
|
| 100 |
+
|
| 101 |
+
def run_user_query(user_id: str, query: str, challenge: dict) -> dict:
|
| 102 |
+
"""
|
| 103 |
+
Executes a query against the user's session database and validates results.
|
| 104 |
+
"""
|
| 105 |
+
# 1. Input Safety Validation
|
| 106 |
+
is_safe, err_msg = is_query_safe(query)
|
| 107 |
+
if not is_safe:
|
| 108 |
+
return {"success": False, "error": err_msg}
|
| 109 |
+
|
| 110 |
+
db_path = get_db_path(user_id)
|
| 111 |
+
if not os.path.exists(db_path):
|
| 112 |
+
return {"success": False, "error": "Database session not initialized. Type 'db init' first."}
|
| 113 |
+
|
| 114 |
+
conn = None
|
| 115 |
+
try:
|
| 116 |
+
conn = sqlite3.connect(db_path)
|
| 117 |
+
# Enable column-name dictionary rows
|
| 118 |
+
conn.row_factory = sqlite3.Row
|
| 119 |
+
cursor = conn.cursor()
|
| 120 |
+
|
| 121 |
+
# Run query with execution timeout (sqlite3 doesn't have a direct query timeout in execute,
|
| 122 |
+
# but we can set busy_timeout, or just rely on local speed. Since it is local SQLite with small mock data,
|
| 123 |
+
# execution time is sub-millisecond unless there is an infinite loop CTE.
|
| 124 |
+
# SQLite detects circular CTEs, but we will wrap execute in a try block)
|
| 125 |
+
cursor.execute(query)
|
| 126 |
+
|
| 127 |
+
# Determine if statement returns rows
|
| 128 |
+
is_select = challenge.get("challenge_type", "SELECT").upper() == "SELECT"
|
| 129 |
+
|
| 130 |
+
rows = []
|
| 131 |
+
columns = []
|
| 132 |
+
rows_affected = cursor.rowcount
|
| 133 |
+
|
| 134 |
+
if cursor.description:
|
| 135 |
+
columns = [col[0] for col in cursor.description]
|
| 136 |
+
db_rows = cursor.fetchall()
|
| 137 |
+
# Convert SQLite Row objects to list of dicts
|
| 138 |
+
rows = [dict(r) for r in db_rows]
|
| 139 |
+
|
| 140 |
+
conn.commit()
|
| 141 |
+
|
| 142 |
+
# 2. Validation Engine
|
| 143 |
+
validation_success = False
|
| 144 |
+
challenge_type = challenge.get("challenge_type", "SELECT").upper()
|
| 145 |
+
|
| 146 |
+
if challenge_type == "SELECT":
|
| 147 |
+
validation_success = validate_select_query(user_id, query, challenge.get("validation_query", ""))
|
| 148 |
+
elif challenge_type == "DML":
|
| 149 |
+
# For UPDATE/DELETE, we check if the user's table states match the target table states
|
| 150 |
+
validation_success = validate_dml_query(user_id, query, challenge)
|
| 151 |
+
|
| 152 |
+
return {
|
| 153 |
+
"success": True,
|
| 154 |
+
"columns": columns,
|
| 155 |
+
"rows": rows,
|
| 156 |
+
"rows_affected": rows_affected if rows_affected >= 0 else 0,
|
| 157 |
+
"is_correct": validation_success,
|
| 158 |
+
"schema": get_db_schema(conn)
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
except Exception as e:
|
| 162 |
+
if conn:
|
| 163 |
+
conn.rollback()
|
| 164 |
+
return {"success": False, "error": f"SQL execution error: {str(e)}"}
|
| 165 |
+
finally:
|
| 166 |
+
if conn:
|
| 167 |
+
conn.close()
|
| 168 |
+
|
| 169 |
+
def validate_select_query(user_id: str, user_query: str, golden_query: str) -> bool:
|
| 170 |
+
"""
|
| 171 |
+
Validates a SELECT query by running both user query and golden query
|
| 172 |
+
and comparing output sets.
|
| 173 |
+
"""
|
| 174 |
+
db_path = get_db_path(user_id)
|
| 175 |
+
conn = None
|
| 176 |
+
try:
|
| 177 |
+
conn = sqlite3.connect(db_path)
|
| 178 |
+
cursor = conn.cursor()
|
| 179 |
+
|
| 180 |
+
# Run user query
|
| 181 |
+
cursor.execute(user_query)
|
| 182 |
+
user_res = cursor.fetchall()
|
| 183 |
+
|
| 184 |
+
# Run golden query
|
| 185 |
+
cursor.execute(golden_query)
|
| 186 |
+
golden_res = cursor.fetchall()
|
| 187 |
+
|
| 188 |
+
# Compare row sets (ignoring row order for general checking, unless they differ)
|
| 189 |
+
# We check set equality of row tuples
|
| 190 |
+
return set(user_res) == set(golden_res)
|
| 191 |
+
except Exception:
|
| 192 |
+
return False
|
| 193 |
+
finally:
|
| 194 |
+
if conn:
|
| 195 |
+
conn.close()
|
| 196 |
+
|
| 197 |
+
def validate_dml_query(user_id: str, user_query: str, challenge: dict) -> bool:
|
| 198 |
+
"""
|
| 199 |
+
Validates UPDATE/DELETE challenges by comparing database states.
|
| 200 |
+
We initialize a reference database, run the golden DML query on it,
|
| 201 |
+
and verify all tables in both databases are identical.
|
| 202 |
+
"""
|
| 203 |
+
db_user_path = get_db_path(user_id)
|
| 204 |
+
db_ref_path = get_db_path(user_id, "_ref")
|
| 205 |
+
|
| 206 |
+
# 1. Initialize reference database
|
| 207 |
+
if os.path.exists(db_ref_path):
|
| 208 |
+
os.remove(db_ref_path)
|
| 209 |
+
|
| 210 |
+
conn_ref = None
|
| 211 |
+
conn_user = None
|
| 212 |
+
try:
|
| 213 |
+
# Spin up reference DB matching original state
|
| 214 |
+
conn_ref = sqlite3.connect(db_ref_path)
|
| 215 |
+
cursor_ref = conn_ref.cursor()
|
| 216 |
+
|
| 217 |
+
# Setup tables and inserts
|
| 218 |
+
for stmt in challenge.get("ddl", "").split(';'):
|
| 219 |
+
if stmt.strip():
|
| 220 |
+
cursor_ref.execute(stmt)
|
| 221 |
+
for stmt in challenge.get("inserts", "").split(';'):
|
| 222 |
+
if stmt.strip():
|
| 223 |
+
cursor_ref.execute(stmt)
|
| 224 |
+
|
| 225 |
+
# Run golden validation DML query on reference DB
|
| 226 |
+
cursor_ref.execute(challenge.get("validation_query", ""))
|
| 227 |
+
conn_ref.commit()
|
| 228 |
+
|
| 229 |
+
# 2. Fetch all tables
|
| 230 |
+
cursor_ref.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';")
|
| 231 |
+
tables = [row[0] for row in cursor_ref.fetchall()]
|
| 232 |
+
|
| 233 |
+
# Connect to user's updated database
|
| 234 |
+
conn_user = sqlite3.connect(db_user_path)
|
| 235 |
+
cursor_user = conn_user.cursor()
|
| 236 |
+
|
| 237 |
+
# Compare contents of every table
|
| 238 |
+
for table in tables:
|
| 239 |
+
# Check user table exists
|
| 240 |
+
cursor_user.execute(f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table}';")
|
| 241 |
+
if not cursor_user.fetchone():
|
| 242 |
+
return False
|
| 243 |
+
|
| 244 |
+
# Get table content from reference DB
|
| 245 |
+
cursor_ref.execute(f"SELECT * FROM {table};")
|
| 246 |
+
ref_rows = cursor_ref.fetchall()
|
| 247 |
+
|
| 248 |
+
# Get table content from user DB
|
| 249 |
+
cursor_user.execute(f"SELECT * FROM {table};")
|
| 250 |
+
user_rows = cursor_user.fetchall()
|
| 251 |
+
|
| 252 |
+
if set(ref_rows) != set(user_rows):
|
| 253 |
+
return False
|
| 254 |
+
|
| 255 |
+
return True
|
| 256 |
+
except Exception:
|
| 257 |
+
return False
|
| 258 |
+
finally:
|
| 259 |
+
if conn_ref:
|
| 260 |
+
conn_ref.close()
|
| 261 |
+
if conn_user:
|
| 262 |
+
conn_user.close()
|
| 263 |
+
# Clean up reference DB file
|
| 264 |
+
if os.path.exists(db_ref_path):
|
| 265 |
+
try:
|
| 266 |
+
os.remove(db_ref_path)
|
| 267 |
+
except Exception:
|
| 268 |
+
pass
|
srs_logic.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime, timedelta, timezone
|
| 2 |
+
|
| 3 |
+
def calculate_next_review(box_level: int) -> datetime:
|
| 4 |
+
intervals = {
|
| 5 |
+
1: 1,
|
| 6 |
+
2: 3,
|
| 7 |
+
3: 7,
|
| 8 |
+
4: 14,
|
| 9 |
+
5: 30
|
| 10 |
+
}
|
| 11 |
+
days = intervals.get(box_level, 1)
|
| 12 |
+
return datetime.now(timezone.utc) + timedelta(days=days)
|
| 13 |
+
|
| 14 |
+
def evaluate_new_box(current_box: int, rating: str) -> int:
|
| 15 |
+
"""
|
| 16 |
+
rating: "Correct", "Mixed", "Incorrect"
|
| 17 |
+
"""
|
| 18 |
+
if rating == "Correct":
|
| 19 |
+
return min(current_box + 1, 5)
|
| 20 |
+
elif rating == "Mixed":
|
| 21 |
+
return current_box
|
| 22 |
+
else: # Incorrect
|
| 23 |
+
return 1
|
| 24 |
+
|
| 25 |
+
def lazy_check_streak(streak_data):
|
| 26 |
+
"""
|
| 27 |
+
Evaluates streak on Login (lazy checking).
|
| 28 |
+
Resets to 0 if the user missed a day.
|
| 29 |
+
"""
|
| 30 |
+
if not streak_data.get('last_active_date'):
|
| 31 |
+
return streak_data
|
| 32 |
+
|
| 33 |
+
now = datetime.now(timezone.utc)
|
| 34 |
+
current_date = now.date()
|
| 35 |
+
last_active_date = datetime.strptime(streak_data['last_active_date'], '%Y-%m-%d').date()
|
| 36 |
+
|
| 37 |
+
delta = (current_date - last_active_date).days
|
| 38 |
+
|
| 39 |
+
if delta > 1:
|
| 40 |
+
streak_data['current_streak'] = 0
|
| 41 |
+
return streak_data
|
| 42 |
+
|
| 43 |
+
def increment_streak(streak_data):
|
| 44 |
+
"""
|
| 45 |
+
Updates the streak object when a practice session is completed.
|
| 46 |
+
"""
|
| 47 |
+
now = datetime.now(timezone.utc)
|
| 48 |
+
current_date = now.date()
|
| 49 |
+
|
| 50 |
+
last_active = streak_data.get('last_active_date')
|
| 51 |
+
if last_active:
|
| 52 |
+
last_active_date = datetime.strptime(last_active, '%Y-%m-%d').date()
|
| 53 |
+
delta = (current_date - last_active_date).days
|
| 54 |
+
else:
|
| 55 |
+
delta = 2 # force increment for first time
|
| 56 |
+
|
| 57 |
+
if delta == 1 or last_active is None:
|
| 58 |
+
streak_data['current_streak'] += 1
|
| 59 |
+
streak_data['longest_streak'] = max(streak_data['longest_streak'], streak_data['current_streak'])
|
| 60 |
+
elif delta > 1:
|
| 61 |
+
# Restarting streak
|
| 62 |
+
streak_data['current_streak'] = 1
|
| 63 |
+
streak_data['longest_streak'] = max(streak_data['longest_streak'], streak_data['current_streak'])
|
| 64 |
+
|
| 65 |
+
streak_data['last_active_date'] = current_date.isoformat()
|
| 66 |
+
return streak_data
|