iamfebin commited on
Commit
aaa634c
·
0 Parent(s):

Configure AlgoSpaced for Hugging Face Spaces deployment

Browse files
Files changed (45) hide show
  1. .gitignore +40 -0
  2. .streamlit/secrets.toml.example +19 -0
  3. Dockerfile +36 -0
  4. System_Design_DSA_Learn.md +384 -0
  5. algospaced-ui/.gitignore +24 -0
  6. algospaced-ui/README.md +73 -0
  7. algospaced-ui/eslint.config.js +22 -0
  8. algospaced-ui/index.html +15 -0
  9. algospaced-ui/package-lock.json +0 -0
  10. algospaced-ui/package.json +38 -0
  11. algospaced-ui/public/favicon.svg +1 -0
  12. algospaced-ui/public/icons.svg +24 -0
  13. algospaced-ui/src/App.css +184 -0
  14. algospaced-ui/src/App.tsx +895 -0
  15. algospaced-ui/src/assets/react.svg +1 -0
  16. algospaced-ui/src/assets/vite.svg +1 -0
  17. algospaced-ui/src/components/Auth.tsx +186 -0
  18. algospaced-ui/src/components/FileExplorer.tsx +948 -0
  19. algospaced-ui/src/components/JourneyPath.tsx +362 -0
  20. algospaced-ui/src/components/MySQLPlayground.tsx +341 -0
  21. algospaced-ui/src/components/PythonChallenge.tsx +237 -0
  22. algospaced-ui/src/components/ReviewQueue.tsx +683 -0
  23. algospaced-ui/src/components/Sidebar.tsx +169 -0
  24. algospaced-ui/src/components/TerminalEmulator.tsx +370 -0
  25. algospaced-ui/src/components/WindowShell.tsx +203 -0
  26. algospaced-ui/src/index.css +147 -0
  27. algospaced-ui/src/main.tsx +10 -0
  28. algospaced-ui/src/supabaseClient.ts +6 -0
  29. algospaced-ui/tsconfig.app.json +25 -0
  30. algospaced-ui/tsconfig.json +7 -0
  31. algospaced-ui/tsconfig.node.json +24 -0
  32. algospaced-ui/vite.config.ts +31 -0
  33. app.py +664 -0
  34. db.py +308 -0
  35. gdrive_sync.py +276 -0
  36. llm_reviewer.py +225 -0
  37. main.py +727 -0
  38. requirements.txt +12 -0
  39. run_app.bat +20 -0
  40. sandbox.py +198 -0
  41. schema.sql +125 -0
  42. scratch/test_explorer_upgrades.py +232 -0
  43. scratch/test_sandbox_sql.py +79 -0
  44. sql_playground.py +268 -0
  45. 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&amp;display=swap" rel="stylesheet"/>
9
+ <link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,700;12..96,800&amp;family=Be+Vietnam+Pro:wght@500;700&amp;family=Courier+Prime:wght@400;700&amp;family=Press+Start+2P&amp;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
+ &times;
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):&#10; # 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