From 979d78d5cb19c3c11a9759c123e0c7d85bab25b3 Mon Sep 17 00:00:00 2001 From: Anshika Guleria Date: Sat, 6 Jun 2026 19:00:24 +0530 Subject: [PATCH 1/2] feat: light theme improvements and onboarding guide --- frontend/website/index.html | 8 +- frontend/website/package-lock.json | 13 +- frontend/website/src/App.jsx | 54 +- frontend/website/src/components/Footer.jsx | 4 +- frontend/website/src/components/Navbar.jsx | 6 +- frontend/website/src/components/Sidebar.jsx | 14 +- frontend/website/src/components/TopBar.jsx | 63 +- frontend/website/src/index.css | 369 +++++++- frontend/website/src/pages/ChallengePage.jsx | 68 +- frontend/website/src/pages/CommunityPage.jsx | 142 +-- frontend/website/src/pages/ContestPage.jsx | 70 +- frontend/website/src/pages/DashboardPage.jsx | 191 ++-- frontend/website/src/pages/LandingPage.jsx | 347 +++++++- frontend/website/src/pages/LoginPage.jsx | 6 +- frontend/website/src/pages/OnboardingPage.jsx | 470 +++++----- frontend/website/src/pages/PracticePage.jsx | 42 +- frontend/website/src/pages/ProfilePage.jsx | 2 +- .../website/src/pages/RecommendationsPage.jsx | 23 +- frontend/website/src/pages/SignupPage.jsx | 8 +- frontend/website/src/services/api.js | 815 ++++-------------- frontend/website/src/services/mockApi.js | 286 ++++++ frontend/website/src/services/mockData.js | 330 +++++++ frontend/website/src/tour/TourContext.jsx | 87 ++ frontend/website/src/tour/TourOverlay.jsx | 245 ++++++ frontend/website/src/tour/TourStarter.jsx | 16 + frontend/website/src/tour/tourSteps.jsx | 133 +++ frontend/website/src/utils/themeStyles.js | 42 + 27 files changed, 2668 insertions(+), 1186 deletions(-) create mode 100644 frontend/website/src/services/mockApi.js create mode 100644 frontend/website/src/services/mockData.js create mode 100644 frontend/website/src/tour/TourContext.jsx create mode 100644 frontend/website/src/tour/TourOverlay.jsx create mode 100644 frontend/website/src/tour/TourStarter.jsx create mode 100644 frontend/website/src/tour/tourSteps.jsx create mode 100644 frontend/website/src/utils/themeStyles.js diff --git a/frontend/website/index.html b/frontend/website/index.html index 9be3fa0..9d16e36 100755 --- a/frontend/website/index.html +++ b/frontend/website/index.html @@ -6,9 +6,15 @@ AlgoLedger — DSA Progress Tracker + - +
diff --git a/frontend/website/package-lock.json b/frontend/website/package-lock.json index 335787a..44777ce 100644 --- a/frontend/website/package-lock.json +++ b/frontend/website/package-lock.json @@ -60,6 +60,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1778,6 +1779,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1819,6 +1821,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1927,6 +1930,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2064,7 +2068,8 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/d3-array": { "version": "3.2.4", @@ -2330,6 +2335,7 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3405,6 +3411,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3483,6 +3490,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -3492,6 +3500,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3901,6 +3910,7 @@ "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -4022,6 +4032,7 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/website/src/App.jsx b/frontend/website/src/App.jsx index c4c48e5..310acbf 100755 --- a/frontend/website/src/App.jsx +++ b/frontend/website/src/App.jsx @@ -1,33 +1,42 @@ -import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' +import { Toaster } from "react-hot-toast" +import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' +import ChallengePage from './pages/ChallengePage' +import CommunityPage from './pages/CommunityPage' +import ContestPage from './pages/ContestPage' +import DashboardPage from './pages/DashboardPage' import LandingPage from './pages/LandingPage' import LoginPage from './pages/LoginPage' -import SignupPage from './pages/SignupPage' import OnboardingPage from './pages/OnboardingPage' -import DashboardPage from './pages/DashboardPage' import PracticePage from './pages/PracticePage' import ProfilePage from './pages/ProfilePage' -import ChallengePage from './pages/ChallengePage' -import ContestPage from './pages/ContestPage' -import CommunityPage from './pages/CommunityPage' -import { Toaster } from "react-hot-toast"; +import SignupPage from './pages/SignupPage' +import { TourProvider } from './tour/TourContext' +import TourOverlay from './tour/TourOverlay' + +function ThemedToaster() { + return ( + + ); +} export default function App() { return ( - - + + + } /> } /> @@ -44,6 +53,7 @@ export default function App() { } /> + ) -} +} \ No newline at end of file diff --git a/frontend/website/src/components/Footer.jsx b/frontend/website/src/components/Footer.jsx index dedcb54..462dd4e 100644 --- a/frontend/website/src/components/Footer.jsx +++ b/frontend/website/src/components/Footer.jsx @@ -6,7 +6,7 @@ export default function Footer() {
-
+
-
+
14 ? '#F59E0B' - : streak > 6 ? '#FB923C' - : streak > 0 ? '#FCD34D' - : '#9CA3AF', + color: streak > 14 ? 'var(--streak-high)' + : streak > 6 ? 'var(--streak-medium)' + : streak > 0 ? 'var(--streak-low)' + : 'var(--streak-none)', }} > {streak > 0 ? `🔥 ${streak} day${streak === 1 ? '' : 's'}` : '⚡ No streak'} @@ -276,19 +279,19 @@ export default function Topbar({ title, subtitle }) { /* ── styles for the bell + dropdown (scoped to .bell-*) ───────────────── */ const BELL_CSS = ` .bell-wrap { position: relative; } -.notif-btn { +.bell-wrap .notif-btn { position: relative; - background: transparent; - border: 1px solid rgba(255,255,255,0.08); - color: #CBD5E1; + background: var(--bg-tertiary); + border: 1px solid var(--surface-border); + color: var(--text-secondary); width: 38px; height: 38px; border-radius: 10px; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; transition: background 0.15s, border-color 0.15s, transform 0.1s; } -.notif-btn:hover { background: rgba(229,166,83,0.08); border-color: rgba(229,166,83,0.25); } -.notif-btn:active { transform: translateY(1px); } +.bell-wrap .notif-btn:hover { background: var(--amber-light); border-color: var(--border-hover); color: var(--text-primary); } +.bell-wrap .notif-btn:active { transform: translateY(1px); } .bell-icon { font-size: 16px; line-height: 1; } .bell-has-unread { animation: bell-swing 2s ease-in-out 1; transform-origin: 50% 10%; } @keyframes bell-swing { @@ -312,7 +315,7 @@ const BELL_CSS = ` font-weight: 800; display: inline-flex; align-items: center; justify-content: center; box-shadow: 0 2px 6px rgba(239,68,68,0.5); - border: 1.5px solid #0B0F1A; + border: 1.5px solid var(--badge-ring); letter-spacing: 0.02em; } @@ -323,10 +326,10 @@ const BELL_CSS = ` width: 360px; max-height: 480px; overflow: auto; - background: rgba(11, 15, 26, 0.96); - border: 1px solid rgba(229,166,83,0.22); + background: var(--popover-bg); + border: 1px solid var(--popover-border); border-radius: 14px; - box-shadow: 0 20px 60px rgba(0,0,0,0.55), 0 0 0 1px rgba(255,255,255,0.02) inset; + box-shadow: var(--shadow-lg), 0 0 0 1px var(--surface-inset) inset; backdrop-filter: blur(14px); z-index: 50; animation: bell-pop-in 0.18s ease-out; @@ -338,29 +341,29 @@ const BELL_CSS = ` .bell-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px 10px; - border-bottom: 1px solid rgba(255,255,255,0.05); + border-bottom: 1px solid var(--popover-divider); position: sticky; top: 0; - background: rgba(11,15,26,0.96); + background: var(--popover-head-bg); z-index: 2; } .bell-head-title { font-family: 'Space Grotesk', sans-serif; font-weight: 800; font-size: 14px; - color: #F1F5F9; + color: var(--text-primary); letter-spacing: -0.01em; } -.bell-head-sub { font-size: 11px; color: #64748B; letter-spacing: 0.02em; } +.bell-head-sub { font-size: 11px; color: var(--text-muted); letter-spacing: 0.02em; } .bell-empty { padding: 46px 22px; text-align: center; - color: #64748B; + color: var(--text-muted); } .bell-empty-emoji { font-size: 32px; margin-bottom: 10px; opacity: 0.8; } .bell-empty-title { - font-size: 14px; font-weight: 700; color: #CBD5E1; margin-bottom: 4px; + font-size: 14px; font-weight: 700; color: var(--text-secondary); margin-bottom: 4px; } -.bell-empty-sub { font-size: 12px; line-height: 1.55; max-width: 240px; margin: 0 auto; } +.bell-empty-sub { font-size: 12px; line-height: 1.55; max-width: 240px; margin: 0 auto; color: var(--text-muted); } .bell-list { list-style: none; margin: 0; padding: 4px 0 8px; } .bell-item { @@ -370,9 +373,9 @@ const BELL_CSS = ` cursor: pointer; transition: background 0.15s; } -.bell-item:hover { background: rgba(255,255,255,0.03); } -.bell-item-unread { background: rgba(229,166,83,0.06); } -.bell-item-unread:hover { background: rgba(229,166,83,0.09); } +.bell-item:hover { background: var(--popover-item-hover); } +.bell-item-unread { background: var(--amber-light); } +.bell-item-unread:hover { background: rgba(229,166,83,0.12); } .bell-item-icon { flex-shrink: 0; @@ -385,21 +388,21 @@ const BELL_CSS = ` .bell-item-body { flex: 1; min-width: 0; } .bell-item-title { font-size: 13.5px; font-weight: 700; - color: #F1F5F9; + color: var(--text-primary); line-height: 1.35; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } .bell-item-msg { margin-top: 3px; font-size: 12px; - color: #94A3B8; + color: var(--text-secondary); line-height: 1.45; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } .bell-item-foot { margin-top: 6px; font-size: 10.5px; - color: #64748B; + color: var(--text-muted); display: flex; align-items: center; gap: 6px; letter-spacing: 0.02em; } diff --git a/frontend/website/src/index.css b/frontend/website/src/index.css index 07a0a66..2f673d3 100644 --- a/frontend/website/src/index.css +++ b/frontend/website/src/index.css @@ -93,6 +93,147 @@ /* === Motion === */ --transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1); + + /* === Semantic aliases (landing + shared) === */ + --ink: var(--text-primary); + --ink-mute: var(--text-secondary); + --ink-faint: var(--text-muted); + --edge: var(--border); + --edge-soft: var(--border-subtle); + + /* === Shell & surfaces === */ + --shell-bg: linear-gradient(140deg, #0B0F1A 0%, #121727 50%, #0B0F1A 100%); + --sidebar-bg: linear-gradient(180deg, #0A0F1E 0%, #080C18 100%); + --sidebar-border: rgba(255, 255, 255, 0.05); + --topbar-bg: rgba(8, 12, 20, 0.7); + --topbar-border: rgba(255, 255, 255, 0.04); + + --surface-glass: rgba(255, 255, 255, 0.026); + --surface-border: rgba(255, 255, 255, 0.068); + --surface-border-subtle: rgba(255, 255, 255, 0.05); + --surface-hover: rgba(255, 255, 255, 0.04); + --surface-inset: rgba(255, 255, 255, 0.04); + --surface-shadow: 0 4px 32px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.04); + --card-bg: rgba(17, 24, 39, 0.7); + --card-border: rgba(255, 255, 255, 0.06); + + --nav-hover-bg: rgba(255, 255, 255, 0.04); + --nav-active-bg: rgba(139, 92, 246, 0.1); + --sidebar-user-bg: linear-gradient(135deg, rgba(229, 166, 83, 0.1), rgba(79, 70, 229, 0.06)); + --sidebar-user-border: rgba(139, 92, 246, 0.15); + --xp-track-bg: rgba(255, 255, 255, 0.06); + + --popover-bg: rgba(11, 15, 26, 0.96); + --popover-border: rgba(229, 166, 83, 0.22); + --popover-head-bg: rgba(11, 15, 26, 0.96); + --popover-divider: rgba(255, 255, 255, 0.05); + --popover-item-hover: rgba(255, 255, 255, 0.03); + --badge-ring: #0B0F1A; + + --chart-tooltip-bg: rgba(8, 12, 30, 0.98); + --chart-tooltip-border: rgba(229, 166, 83, 0.3); + + --heatmap-0: rgba(255, 255, 255, 0.04); + --heatmap-1: rgba(229, 166, 83, 0.22); + --heatmap-2: rgba(229, 166, 83, 0.48); + --heatmap-3: rgba(229, 166, 83, 0.76); + --heatmap-4: #E5A653; + + --code-bg: rgba(8, 12, 30, 0.75); + --code-border: rgba(255, 255, 255, 0.06); + --code-inline-bg: rgba(229, 166, 83, 0.1); + --code-inline-border: rgba(229, 166, 83, 0.18); + --code-inline-color: #F3C887; + + --on-accent: #ffffff; + --on-gradient: #1C1608; + + --toast-bg: #111827; + --toast-color: #fff; + --toast-border: rgba(255, 255, 255, 0.08); + + /* === Difficulty Colors === */ + --difficulty-easy: #22C55E; + --difficulty-easy-bg: rgba(34, 197, 94, 0.10); + --difficulty-easy-glow: rgba(34, 197, 94, 0.30); + + --difficulty-medium: #F59E0B; + --difficulty-medium-bg: rgba(245, 158, 11, 0.10); + --difficulty-medium-glow: rgba(245, 158, 11, 0.30); + + --difficulty-hard: #EF4444; + --difficulty-hard-bg: rgba(239, 68, 68, 0.10); + --difficulty-hard-glow: rgba(239, 68, 68, 0.30); + + /* === Platform Colors === */ + --platform-leetcode: #FFA116; + --platform-codeforces: #1890FF; + --platform-geeksforgeeks: #308D46; + + /* === Streak Colors === */ + --streak-high: #F59E0B; + --streak-medium: #FB923C; + --streak-low: #FCD34D; + --streak-none: #6B7280; + + /* === Category Colors === */ + --category-mission: #9F8FE3; + --category-mission-bg: rgba(159, 143, 227, 0.12); + --category-mission-glow: rgba(159, 143, 227, 0.35); + + --category-weakness: #EF4444; + --category-weakness-bg: rgba(239, 68, 68, 0.10); + --category-weakness-glow: rgba(239, 68, 68, 0.30); + + --category-levelup: #F59E0B; + --category-levelup-bg: rgba(245, 158, 11, 0.10); + --category-levelup-glow: rgba(245, 158, 11, 0.30); + + --category-explore: #38BDF8; + --category-explore-bg: rgba(56, 189, 248, 0.10); + --category-explore-glow: rgba(56, 189, 248, 0.30); + + --category-stretch: #E5A653; + --category-stretch-bg: rgba(229, 166, 83, 0.12); + --category-stretch-glow: rgba(229, 166, 83, 0.35); + + /* === Skill Stage Colors === */ + --stage-beginner: #22C55E; + --stage-intermediate: #F59E0B; + --stage-advanced: #EF4444; + --stage-expert: #9F8FE3; + + /* === Nav Item Colors === */ + --nav-dashboard: #6366F1; + --nav-training: #22D3EE; + --nav-challenges: #F59E0B; + --nav-community: #34D399; + --nav-profile: #F472B6; + + /* === Topic Colors === */ + --topic-arrays-dark: #E5A653; + --topic-arrays-light: #9F8FE3; + --topic-graphs-dark: #10B981; + --topic-graphs-light: #34D399; + --topic-dp-dark: #F59E0B; + --topic-dp-light: #FCD34D; + --topic-trees-dark: #9F8FE3; + --topic-trees-light: #9F8FE3; + --topic-binsearch-dark: #3B82F6; + --topic-binsearch-light: #60A5FA; + --topic-sysdesign-dark: #EC4899; + --topic-sysdesign-light: #F472B6; + --topic-interview-dark: #14B8A6; + --topic-interview-light: #2DD4BF; + --topic-strings-dark: #F97316; + --topic-strings-light: #FB923C; + --topic-backtrack-dark: #EF4444; + --topic-backtrack-light: #F87171; + --topic-general-dark: #94A3B8; + --topic-general-light: #CBD5E1; + + /* === Accent Colors for Emphasis === */ + --emphasis-color: #E5A653; } [data-theme="light"] { @@ -105,11 +246,196 @@ --text-primary: #0F172A; --text-secondary: #334155; --text-muted: #64748B; + --text-accent: #B45309; --border: rgba(15, 23, 42, 0.12); --border-subtle: rgba(15, 23, 42, 0.08); + --border-hover: rgba(15, 23, 42, 0.22); --bg-glass: rgba(255, 255, 255, 0.7); + + --shell-bg: linear-gradient(140deg, #F8FAFC 0%, #FFFFFF 50%, #F1F5F9 100%); + --sidebar-bg: linear-gradient(180deg, #FFFFFF 0%, #F8FAFC 100%); + --sidebar-border: rgba(15, 23, 42, 0.08); + --topbar-bg: rgba(255, 255, 255, 0.88); + --topbar-border: rgba(15, 23, 42, 0.08); + + --surface-glass: rgba(15, 23, 42, 0.03); + --surface-border: rgba(15, 23, 42, 0.10); + --surface-border-subtle: rgba(15, 23, 42, 0.06); + --surface-hover: rgba(15, 23, 42, 0.04); + --surface-inset: rgba(15, 23, 42, 0.03); + --surface-glow: rgba(255, 255, 255, 0.18); + --surface-shadow: 0 4px 24px rgba(15, 23, 42, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.9); + --card-bg: rgba(255, 255, 255, 0.95); + --card-border: rgba(15, 23, 42, 0.08); + + --nav-hover-bg: rgba(15, 23, 42, 0.04); + --nav-active-bg: rgba(159, 143, 227, 0.12); + --sidebar-user-bg: linear-gradient(135deg, rgba(229, 166, 83, 0.08), rgba(159, 143, 227, 0.06)); + --sidebar-user-border: rgba(159, 143, 227, 0.2); + --xp-track-bg: rgba(15, 23, 42, 0.08); + + --popover-bg: rgba(255, 255, 255, 0.98); + --popover-border: rgba(15, 23, 42, 0.12); + --popover-head-bg: rgba(255, 255, 255, 0.98); + --popover-divider: rgba(15, 23, 42, 0.08); + --popover-item-hover: rgba(15, 23, 42, 0.04); + --badge-ring: #FFFFFF; + + --chart-tooltip-bg: rgba(255, 255, 255, 0.98); + --chart-tooltip-border: rgba(15, 23, 42, 0.12); + + --heatmap-0: rgba(15, 23, 42, 0.06); + --heatmap-1: rgba(229, 166, 83, 0.18); + --heatmap-2: rgba(229, 166, 83, 0.38); + --heatmap-3: rgba(229, 166, 83, 0.58); + --heatmap-4: #B45309; + + --code-bg: rgba(241, 245, 249, 0.95); + --code-border: rgba(15, 23, 42, 0.1); + --code-inline-bg: rgba(229, 166, 83, 0.12); + --code-inline-border: rgba(180, 83, 9, 0.25); + --code-inline-color: #92400E; + + --shadow-sm: 0 1px 4px rgba(15, 23, 42, 0.08); + --shadow-md: 0 4px 20px rgba(15, 23, 42, 0.10); + --shadow-lg: 0 8px 40px rgba(15, 23, 42, 0.12); + --shadow-glow: 0 0 24px rgba(229, 166, 83, 0.15); + + --toast-bg: #FFFFFF; + --toast-color: #0F172A; + --toast-border: rgba(15, 23, 42, 0.12); + + /* === Light Theme Overrides for Color Systems === */ + /* Difficulty Colors - adjusted for light backgrounds */ + --difficulty-easy: #059669; + --difficulty-easy-bg: rgba(5, 150, 105, 0.12); + --difficulty-easy-glow: rgba(5, 150, 105, 0.25); + + --difficulty-medium: #92400E; + --difficulty-medium-bg: rgba(146, 64, 14, 0.12); + --difficulty-medium-glow: rgba(146, 64, 14, 0.25); + + --difficulty-hard: #991B1B; + --difficulty-hard-bg: rgba(153, 27, 27, 0.12); + --difficulty-hard-glow: rgba(153, 27, 27, 0.25); + + /* Platform Colors - adjusted for light backgrounds */ + --platform-leetcode: #D97706; + --platform-codeforces: #0369A1; + --platform-geeksforgeeks: #166534; + + /* Streak Colors - adjusted for light backgrounds */ + --streak-high: #D97706; + --streak-medium: #EA580C; + --streak-low: #B45309; + --streak-none: #6B7280; + + /* Category Colors - adjusted for light backgrounds */ + --category-mission: #6D28D9; + --category-mission-bg: rgba(109, 40, 217, 0.12); + --category-mission-glow: rgba(109, 40, 217, 0.25); + + --category-weakness: #991B1B; + --category-weakness-bg: rgba(153, 27, 27, 0.12); + --category-weakness-glow: rgba(153, 27, 27, 0.25); + + --category-levelup: #92400E; + --category-levelup-bg: rgba(146, 64, 14, 0.12); + --category-levelup-glow: rgba(146, 64, 14, 0.25); + + --category-explore: #0369A1; + --category-explore-bg: rgba(3, 105, 161, 0.12); + --category-explore-glow: rgba(3, 105, 161, 0.25); + + --category-stretch: #B45309; + --category-stretch-bg: rgba(180, 83, 9, 0.12); + --category-stretch-glow: rgba(180, 83, 9, 0.25); + + /* Skill Stage Colors */ + --stage-beginner: #059669; + --stage-intermediate: #92400E; + --stage-advanced: #991B1B; + --stage-expert: #6D28D9; + + /* Nav Item Colors - adjusted for light backgrounds */ + --nav-dashboard: #4F46E5; + --nav-training: #0891B2; + --nav-challenges: #D97706; + --nav-community: #059669; + --nav-profile: #BE185D; + + /* Topic Colors - adjusted for light backgrounds */ + --topic-arrays-dark: #B45309; + --topic-arrays-light: #6D28D9; + --topic-graphs-dark: #059669; + --topic-graphs-light: #10B981; + --topic-dp-dark: #92400E; + --topic-dp-light: #B45309; + --topic-trees-dark: #6D28D9; + --topic-trees-light: #6D28D9; + --topic-binsearch-dark: #1E40AF; + --topic-binsearch-light: #2563EB; + --topic-sysdesign-dark: #9D174D; + --topic-sysdesign-light: #BE185D; + --topic-interview-dark: #0D9488; + --topic-interview-light: #14B8A6; + --topic-strings-dark: #C2410C; + --topic-strings-light: #EA580C; + --topic-backtrack-dark: #991B1B; + --topic-backtrack-light: #DC2626; + --topic-general-dark: #475569; + --topic-general-light: #64748B; + + /* Accent Colors for Emphasis */ + --emphasis-color: #B45309; +} + +[data-theme="light"] .card:hover { + border-color: rgba(159, 143, 227, 0.28); + box-shadow: 0 12px 40px rgba(15, 23, 42, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.8); +} + +[data-theme="light"] .lp-pricing-divider, +[data-theme="light"] .lp-footer { + border-color: var(--border-subtle); +} + +[data-theme="light"] .lp-pricing-divider { + background: var(--border-subtle); +} + +[data-theme="light"] .lp-cta-banner-bg { + background: linear-gradient(135deg, #EDE9FE, #E0F2FE, #F8FAFC); +} + +[data-theme="light"] .lp-cta-banner-bg::before { + background: + radial-gradient(ellipse at top left, rgba(229, 166, 83, 0.2), transparent 50%), + radial-gradient(ellipse at bottom right, rgba(136, 192, 163, 0.15), transparent 50%); +} + +[data-theme="light"] .lp-section-head, +[data-theme="light"] .lp-pricing-card, +[data-theme="light"] .lp-feature-card, +[data-theme="light"] .lp-faq-item, +[data-theme="light"] .lp-team-card, +[data-theme="light"] .lp-placement-card { + border-color: var(--border-subtle); +} + +[data-theme="light"] .lp-ghost-num { + color: rgba(15, 23, 42, 0.06); + -webkit-text-stroke: 1px rgba(15, 23, 42, 0.1); +} + +.contest-page { + min-height: 100vh; + background: var(--shell-bg); + color: var(--text-primary); + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + position: relative; } /* ── Reset ── */ @@ -462,11 +788,11 @@ body::before { CARDS ============================================================ */ .card { - background: rgba(17, 24, 39, 0.7); + background: var(--card-bg); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); - border: 1px solid rgba(255, 255, 255, 0.06); - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.04); + border: 1px solid var(--card-border); + box-shadow: var(--surface-shadow); border-radius: var(--radius-lg); padding: 24px; position: relative; @@ -485,6 +811,15 @@ body::before { border-radius: var(--radius-md); } +.glass-card { + background: var(--surface-glass); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + border: 1px solid var(--surface-border); + box-shadow: var(--surface-shadow); + border-radius: var(--radius-lg); +} + /* ============================================================ INPUTS ============================================================ */ @@ -583,6 +918,8 @@ body::before { .app-shell { display: flex; min-height: 100vh; + background: var(--shell-bg); + color: var(--text-primary); } .main-content { @@ -605,8 +942,8 @@ body::before { ============================================================ */ .sidebar { width: var(--sidebar-width); - background: linear-gradient(180deg, #0A0F1E 0%, #080C18 100%); - border-right: 1px solid rgba(255, 255, 255, 0.05); + background: var(--sidebar-bg); + border-right: 1px solid var(--sidebar-border); height: 100vh; position: fixed; top: 0; @@ -663,8 +1000,8 @@ body::before { /* User card */ .sidebar-user { margin: 0 12px 8px; - background: linear-gradient(135deg, rgba(229, 166, 83, 0.1), rgba(79, 70, 229, 0.06)); - border: 1px solid rgba(139, 92, 246, 0.15); + background: var(--sidebar-user-bg); + border: 1px solid var(--sidebar-user-border); border-radius: var(--radius-lg); padding: 12px 14px; display: flex; @@ -736,12 +1073,12 @@ body::before { } .nav-item:hover { - background: rgba(255, 255, 255, 0.04); + background: var(--nav-hover-bg); color: var(--text-primary); } .nav-item.active { - background: rgba(139, 92, 246, 0.1); + background: var(--nav-active-bg); color: var(--text-primary); font-weight: 600; } @@ -851,7 +1188,7 @@ body::before { /* XP bar */ .sidebar-bottom { padding: 12px; - border-top: 1px solid rgba(255, 255, 255, 0.04); + border-top: 1px solid var(--sidebar-border); } .sidebar-xp-bar { @@ -880,7 +1217,7 @@ body::before { .sidebar-xp-track { height: 4px; - background: rgba(255, 255, 255, 0.06); + background: var(--xp-track-bg); border-radius: 2px; overflow: hidden; } @@ -914,10 +1251,10 @@ body::before { left: var(--sidebar-width); right: 0; height: var(--topbar-height); - background: rgba(8, 12, 20, 0.7); + background: var(--topbar-bg); backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); - border-bottom: 1px solid rgba(255, 255, 255, 0.04); + border-bottom: 1px solid var(--topbar-border); display: flex; align-items: center; justify-content: space-between; @@ -2794,12 +3131,12 @@ body::before { -webkit-backdrop-filter: blur(22px); border: 1px solid var(--edge); border-radius: 18px; - box-shadow: 0 14px 40px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(237, 228, 206, 0.04); - transition: box-shadow 0.3s, border-color 0.3s; + box-shadow: none; + transition: border-color 0.3s; } .ml-nav-scrolled { - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(237, 228, 206, 0.05); + box-shadow: none; border-color: rgba(229, 166, 83, 0.35); } diff --git a/frontend/website/src/pages/ChallengePage.jsx b/frontend/website/src/pages/ChallengePage.jsx index fd1c97c..3901dc2 100644 --- a/frontend/website/src/pages/ChallengePage.jsx +++ b/frontend/website/src/pages/ChallengePage.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react' +import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' import Sidebar from '../components/Sidebar' import Topbar from '../components/TopBar' @@ -55,8 +55,8 @@ function PresetPicker({ value, onChange }) { return (
{n}
- {children} + {children}
) } @@ -110,7 +110,7 @@ function SectionLabel({ n, children }) { // ─── Back button ────────────────────────────────────────────────────────────── function BackBtn({ onBack }) { return ( - ) @@ -154,12 +154,12 @@ function DuelSetup({ onBack, onSuccess, myEmail }) {
⚔️
Challenge Sent!
-
- Duel #{success.id} sent to {success.opponentName || success.opponentId}.
Waiting for them to accept. +
+ Duel #{success.id} sent to {success.opponentName || success.opponentId}.
Waiting for them to accept.
- +
@@ -200,9 +200,9 @@ function DuelSetup({ onBack, onSuccess, myEmail }) { spellCheck={false} maxLength={30} placeholder="their_handle" - style={{ width: '100%', padding: '12px 14px 12px 36px', borderRadius: 12, background: 'rgba(255,255,255,.04)', border: '1.5px solid rgba(255,255,255,.1)', color: '#F1F5F9', fontSize: 14, outline: 'none', boxSizing: 'border-box', transition: 'border-color .2s' }} + style={{ width: '100%', padding: '12px 14px 12px 36px', borderRadius: 12, background: 'var(--surface-glass)', border: '1.5px solid var(--surface-border)', color: 'var(--text-primary)', fontSize: 14, outline: 'none', boxSizing: 'border-box', transition: 'border-color .2s' }} onFocus={e => e.target.style.borderColor = 'rgba(229,166,83,.5)'} - onBlur={e => e.target.style.borderColor = 'rgba(255,255,255,.1)'} + onBlur={e => e.target.style.borderColor = 'var(--surface-border)'} />
@@ -276,9 +276,9 @@ function ContestSetup({ onBack }) { setName(e.target.value)} placeholder="e.g. Friday Night Grind, Team Qualifier…" - style={{ width: '100%', padding: '11px 14px', borderRadius: 11, background: 'rgba(255,255,255,.04)', border: '1.5px solid rgba(255,255,255,.1)', color: '#F1F5F9', fontSize: 13.5, outline: 'none', boxSizing: 'border-box', transition: 'border-color .2s' }} - onFocus={e => e.target.style.borderColor = 'rgba(229,166,83,.5)'} - onBlur={e => e.target.style.borderColor = 'rgba(255,255,255,.1)'} + style={{ width: '100%', padding: '11px 14px', borderRadius: 11, background: 'var(--surface-glass)', border: '1.5px solid var(--surface-border)', color: 'var(--text-primary)', fontSize: 13.5, outline: 'none', boxSizing: 'border-box', transition: 'border-color .2s' }} + onFocus={e => e.target.style.borderColor = 'var(--border-hover)'} + onBlur={e => e.target.style.borderColor = 'var(--surface-border)'} />
@@ -292,9 +292,9 @@ function ContestSetup({ onBack }) { type="email" value={em} onChange={e => updateEmail(i, e.target.value)} placeholder={`Participant ${i + 1} email`} - style={{ flex: 1, padding: '10px 14px', borderRadius: 10, background: 'rgba(255,255,255,.04)', border: '1.5px solid rgba(255,255,255,.08)', color: '#F1F5F9', fontSize: 13, outline: 'none', transition: 'border-color .2s' }} - onFocus={e => e.target.style.borderColor = 'rgba(229,166,83,.4)'} - onBlur={e => e.target.style.borderColor = 'rgba(255,255,255,.08)'} + style={{ flex: 1, padding: '10px 14px', borderRadius: 10, background: 'var(--surface-glass)', border: '1.5px solid var(--surface-border)', color: 'var(--text-primary)', fontSize: 13, outline: 'none', transition: 'border-color .2s' }} + onFocus={e => e.target.style.borderColor = 'var(--border-hover)'} + onBlur={e => e.target.style.borderColor = 'var(--surface-border)'} /> {emails.length > 1 && ( @@ -307,8 +307,8 @@ function ContestSetup({ onBack }) {
{/* Max participants */} -
- Max participants +
+ Max participants setMaxPeople(Math.min(50, Math.max(2, Number(e.target.value) || 2)))} @@ -335,7 +335,7 @@ function ContestSetup({ onBack }) {
🔗 Invite Link
-
+
{inviteLink}
@@ -210,14 +210,14 @@ function PostView({ post, onBack, onLike, myEmail }) { {/* Author bar */} -
+
{(post.authorName || '?')[0].toUpperCase()}
-
{post.authorName || post.userId}
-
+
{post.authorName || post.userId}
+
{post.authorUsername && @{post.authorUsername} · } {timeAgo(post.createdAt)} · {mins} min read
@@ -247,12 +247,12 @@ function PostView({ post, onBack, onLike, myEmail }) {
-
@@ -263,8 +263,8 @@ function PostView({ post, onBack, onLike, myEmail }) { {/* Bottom action bar */} -
-
{form.content && ( - )} @@ -669,7 +669,7 @@ function WriteEditor({ onCancel, onPublished }) {

{form.title || Untitled post}

-
{words} words · {mins} min read
+
{words} words · {mins} min read
) : ( @@ -703,7 +703,7 @@ function WriteEditor({ onCancel, onPublished }) { /> {/* Divider */} -
+
{/* ── Formatting toolbar ── */}
@@ -783,7 +783,7 @@ const MD_CSS = ` padding: 6px 8px; margin-bottom: 14px; background: rgba(15, 23, 42, 0.55); - border: 1px solid rgba(255, 255, 255, 0.06); + border: 1px solid var(--surface-border-subtle); border-radius: 12px; backdrop-filter: blur(6px); /* NB: deliberately NOT sticky — sticky + an auto-growing textarea below @@ -817,7 +817,7 @@ const MD_CSS = ` .md-tb-mono { font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace; font-size: 12px; } .md-tb-sep { width: 1px; height: 18px; - background: rgba(255, 255, 255, 0.08); + background: var(--surface-border); margin: 0 4px; display: inline-block; } @@ -830,7 +830,7 @@ const MD_CSS = ` align-items: center; margin-top: 18px; padding-top: 14px; - border-top: 1px dashed rgba(255, 255, 255, 0.06); + border-top: 1px dashed var(--surface-border-subtle); font-size: 12px; color: #64748B; } @@ -841,25 +841,51 @@ const MD_CSS = ` background: rgba(15, 23, 42, 0.6); padding: 2px 7px; border-radius: 5px; - border: 1px solid rgba(255, 255, 255, 0.05); + border: 1px solid var(--surface-border-subtle); } -.md-hint-muted { - margin-left: auto; - opacity: 0.6; - font-style: italic; + +[data-theme="light"] .md-toolbar { + background: rgba(255, 255, 255, 0.94); + border-color: rgba(15, 23, 42, 0.12); + color: var(--text-primary); +} + +[data-theme="light"] .md-tb-btn { + color: var(--text-primary); +} + +[data-theme="light"] .md-tb-btn:hover { + background: rgba(15, 23, 42, 0.04); + color: var(--text-primary); + border-color: rgba(15, 23, 42, 0.12); +} + +[data-theme="light"] .md-hint { + border-top-color: rgba(15, 23, 42, 0.08); + color: var(--text-secondary); +} + +[data-theme="light"] .md-hint-k { + color: var(--text-primary); + background: rgba(15, 23, 42, 0.06); + border-color: rgba(15, 23, 42, 0.08); +} + +[data-theme="light"] .md-hint-muted { + color: var(--text-muted); } /* ── Rendered body — real blog typography ── */ .md-body { font-size: 16px; line-height: 1.8; - color: #CBD5E1; + color: var(--text-secondary); letter-spacing: 0.005em; } .md-body > * + * { margin-top: 18px; } .md-h1, .md-h2, .md-h3 { - color: #F1F5F9; + color: var(--text-primary); font-weight: 800; letter-spacing: -0.02em; line-height: 1.25; @@ -869,38 +895,38 @@ const MD_CSS = ` .md-h2 { font-size: 22px; padding-bottom: 6px; - border-bottom: 1px solid rgba(255, 255, 255, 0.07); + border-bottom: 1px solid var(--surface-border-subtle); } -.md-h3 { font-size: 18px; color: #E2E8F0; } +.md-h3 { font-size: 18px; color: var(--text-primary); } .md-p { margin: 0; } .md-body strong { - color: #F1F5F9; + color: var(--text-primary); font-weight: 800; } -.md-body em { color: #E2E8F0; font-style: italic; } +.md-body em { color: var(--text-secondary); font-style: italic; } .md-icode { - font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace; + font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 0.88em; padding: 2px 7px; border-radius: 5px; - background: rgba(229, 166, 83, 0.1); - border: 1px solid rgba(229, 166, 83, 0.18); - color: #F3C887; + background: var(--code-inline-bg); + border: 1px solid var(--code-inline-border); + color: var(--code-inline-color); } .md-block { - font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace; + font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 13.5px; line-height: 1.6; padding: 16px 18px; - background: rgba(8, 12, 30, 0.75); - border: 1px solid rgba(255, 255, 255, 0.06); + background: var(--code-bg); + border: 1px solid var(--code-border); border-left: 3px solid rgba(229, 166, 83, 0.55); border-radius: 10px; - color: #E2E8F0; + color: var(--text-primary); overflow-x: auto; } .md-block code { background: none; border: none; padding: 0; color: inherit; font-size: inherit; } @@ -908,7 +934,7 @@ const MD_CSS = ` .md-ul, .md-ol { margin: 0; padding-left: 26px; - color: #CBD5E1; + color: var(--text-secondary); } .md-ul li, .md-ol li { margin: 8px 0; @@ -923,7 +949,7 @@ const MD_CSS = ` padding: 4px 18px; border-left: 3px solid rgba(159, 143, 227, 0.7); background: rgba(159, 143, 227, 0.05); - color: #E2E8F0; + color: var(--text-secondary); font-style: italic; border-radius: 0 10px 10px 0; } @@ -1013,7 +1039,7 @@ export default function CommunityPage() { // ── Write view ── if (view === 'write') { return ( -
+
@@ -1028,7 +1054,7 @@ export default function CommunityPage() { // ── Post view ── if (view === 'post' && openPost) { return ( -
+
@@ -1042,7 +1068,7 @@ export default function CommunityPage() { // ── Feed view ── return ( -
+
@@ -1051,7 +1077,7 @@ export default function CommunityPage() { {/* ── Header row ── */}
-
+
{[['feed', '📰 Feed'], ['mine', '✍️ My Posts']].map(([k, l]) => ( ) @@ -1081,14 +1107,14 @@ export default function CommunityPage() { {loading && page === 0 && (
-
Loading community feed…
+
Loading community feed…
)} {!loading && posts.length === 0 && ( -
+
📝
-
No posts yet {topic !== 'all' ? `in "${topic}"` : ''}
+
No posts yet {topic !== 'all' ? `in "${topic}"` : ''}
Be the first to share something!
@@ -1113,9 +1139,9 @@ export default function CommunityPage() { {tab === 'mine' && ( myPosts.length === 0 ? ( -
+
✍️
-
No posts yet
+
No posts yet
Share a tip, a walkthrough, or something that helped you crack a problem.
diff --git a/frontend/website/src/pages/ContestPage.jsx b/frontend/website/src/pages/ContestPage.jsx index b7ea21b..4b6dcf6 100644 --- a/frontend/website/src/pages/ContestPage.jsx +++ b/frontend/website/src/pages/ContestPage.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import * as api from '../services/api' import { useProfilePic } from '../utils/profilePic' @@ -121,8 +121,8 @@ export default function ContestPage() { /* ── loading / error ── */ if (loading) return ( -
-
+
+
Loading contest…
@@ -131,7 +131,7 @@ export default function ContestPage() { ) if (error || !challenge) return ( -
+
⚠️
{error || 'Challenge not found'}
@@ -160,7 +160,7 @@ export default function ContestPage() { const isDraw = isCompleted && !challenge.winnerId return ( -
+
{/* ambient */}
@@ -170,7 +170,7 @@ export default function ContestPage() {
⚠️
Exited Fullscreen!
-
Stay in fullscreen during the contest. Your timer is still running.
+
Stay in fullscreen during the contest. Your timer is still running.
)} @@ -180,7 +180,7 @@ export default function ContestPage() { {/* ── HEADER ── */}
- +
{cfg.icon}
@@ -207,7 +207,7 @@ export default function ContestPage() { )} {isActive && !document.fullscreenElement && ( - + )} {isPending && isOpponent && ( @@ -229,17 +229,17 @@ export default function ContestPage() { {/* ── TIMER BAR (only when active) ── */} {isActive && ( -
+
{fmtTime(secsLeft)}
Remaining
- Time elapsed + Time elapsed {secsLeft < 120 ? '⚠️ Almost out!' : secsLeft < 300 ? 'Hurry up!' : 'On track'}
-
+
@@ -258,23 +258,23 @@ export default function ContestPage() {
{isDraw ? "It's a Draw!" : iWon ? 'You Won!' : 'You Lost'}
-
+
{isDraw ? 'Both players tied on problems solved.' : `${challenge.winnerId === challenge.challengerId ? challenge.challengerName : challenge.opponentName} wins this contest.`}
-
{myProgress.solved || 0}
-
Your solves
+
{myProgress.solved || 0}
+
Your solves
-
+
-
{theirProgress.solved || 0}
-
Opponent solves
+
{theirProgress.solved || 0}
+
Opponent solves
-
+
-
{totalProblems}
-
Total problems
+
{totalProblems}
+
Total problems
@@ -290,7 +290,7 @@ export default function ContestPage() { if (item === null) return (
VS
- {isActive &&
} + {isActive &&
}
) const { prog, isMe } = item @@ -299,8 +299,8 @@ export default function ContestPage() { const barColor = isWinner ? '#22C55E' : isMe ? '#E5A653' : '#475569' return (
@@ -319,12 +319,12 @@ export default function ContestPage() { {/* Score */}
- {prog?.solved || 0} + {prog?.solved || 0} / {totalProblems} solved
{/* Progress bar */} -
+
0 ? `0 0 8px ${barColor}60` : 'none' }} />
@@ -340,11 +340,11 @@ export default function ContestPage() {
{/* ── PROBLEMS ── */} -
+
Contest Problems
-
+
{isActive ? 'Solve on LeetCode · Solves detected automatically after syncing' : isPending ? 'Problems are revealed when the contest starts' : 'Final problem set for this contest'}
@@ -356,7 +356,7 @@ export default function ContestPage() {
{!challenge.problems?.length ? ( -
+
{isPending ? '🔒 Problems are locked until the contest starts.' : 'No problems assigned.'}
) : ( @@ -369,12 +369,12 @@ export default function ContestPage() { return (
{ e.currentTarget.style.background = 'rgba(255,255,255,.04)'; e.currentTarget.style.borderColor = 'rgba(229,166,83,.2)' }} - onMouseLeave={e => { e.currentTarget.style.background = 'rgba(255,255,255,.02)'; e.currentTarget.style.borderColor = 'rgba(255,255,255,.05)' }} + onMouseEnter={e => { e.currentTarget.style.background = 'var(--surface-hover)'; e.currentTarget.style.borderColor = 'rgba(229,166,83,.2)' }} + onMouseLeave={e => { e.currentTarget.style.background = 'var(--surface-glass)'; e.currentTarget.style.borderColor = 'var(--surface-border)' }} >
{/* Number badge */} @@ -383,7 +383,7 @@ export default function ContestPage() {
{title}
{p.difficulty} - {p.platform && {p.platform}} + {p.platform && {p.platform}}
@@ -396,7 +396,7 @@ export default function ContestPage() { ].map(({ prog, label, color }) => { const solved = prog?.solvedTitles?.includes(p.titleSlug) || prog?.solvedTitles?.includes(title) return ( -
+
{solved ? '✓' : '·'}
) @@ -418,7 +418,7 @@ export default function ContestPage() { {isActive && (
-
+
💡 Scoring: Solve problems on LeetCode directly. Click Sync Solves above or visit the Dashboard and hit Sync to record your progress. Leaderboard auto-refreshes every 10 seconds.
diff --git a/frontend/website/src/pages/DashboardPage.jsx b/frontend/website/src/pages/DashboardPage.jsx index 68638d2..41fc174 100644 --- a/frontend/website/src/pages/DashboardPage.jsx +++ b/frontend/website/src/pages/DashboardPage.jsx @@ -1,21 +1,43 @@ -import { useState, useEffect } from 'react' +import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' import { - BarChart, Bar, AreaChart, Area, PieChart, Pie, Cell, - RadarChart, PolarGrid, PolarAngleAxis, Radar, - ReferenceLine, XAxis, YAxis, Tooltip, ResponsiveContainer, + Area, + AreaChart, + Bar, + BarChart, + Cell, + Pie, + PieChart, + PolarAngleAxis, + PolarGrid, + Radar, + RadarChart, + ReferenceLine, + ResponsiveContainer, + Tooltip, + XAxis, YAxis, } from 'recharts' import Sidebar from '../components/Sidebar' import Topbar from '../components/TopBar' import * as api from '../services/api' +import { useTour } from '../tour/TourContext' import { - computeTopicStats, detectWeakTopics, computeSkillRadar, - computeEfficiency, computeConsistency, computeWeeklyGrowth, - computeDailyTrend, computeContestReadiness, - computeRecommendations, computePrediction, computePerformanceScore, - computeDifficultyPace, computeMomentum, + computeConsistency, + computeContestReadiness, + computeDailyTrend, + computeDifficultyPace, + computeEfficiency, + computeMomentum, + computePerformanceScore, + computePrediction, + computeRecommendations, + computeSkillRadar, + computeTopicStats, + computeWeeklyGrowth, + detectWeakTopics, scoreLabel, } from '../utils/analytics' +import { CARD, CHART_TOOLTIP, HEATMAP_COLORS, PROGRESS_TRACK } from '../utils/themeStyles' /* ─────────────────────────────── helpers ─────────────────────────────── */ function buildHeatmap(calMap) { @@ -41,36 +63,26 @@ function buildHeatmap(calMap) { /* ── tooltip ── */ const TT = ({ active, payload, label }) => active && payload?.length ? ( -
-
{label}
-
+
+
{label}
+
{payload[0].value} - {payload[0].name} + {payload[0].name}
) : null -/* ── shared card style ── */ -const CARD = { - background: 'rgba(255,255,255,0.026)', - backdropFilter: 'blur(24px)', - border: '1px solid rgba(255,255,255,0.068)', - boxShadow: '0 4px 32px rgba(0,0,0,.3), inset 0 1px 0 rgba(255,255,255,.04)', - borderRadius: 18, - padding: 22, -} - -const HM_COLORS = ['rgba(255,255,255,0.04)', 'rgba(229,166,83,0.22)', 'rgba(229,166,83,0.48)', 'rgba(229,166,83,0.76)', '#E5A653'] -const P_COLOR = { high: '#EF4444', medium: '#F59E0B', low: '#22C55E' } +const HM_COLORS = HEATMAP_COLORS +const P_COLOR = { high: 'var(--difficulty-hard)', medium: 'var(--difficulty-medium)', low: 'var(--difficulty-easy)' } const PMETA = { - leetcode: { label: 'LeetCode', color: '#FFA116', icon: '🟡' }, - codeforces: { label: 'Codeforces', color: '#1890FF', icon: '🔵' }, + leetcode: { label: 'LeetCode', color: 'var(--platform-leetcode)', icon: '🟡' }, + codeforces: { label: 'Codeforces', color: 'var(--platform-codeforces)', icon: '🔵' }, } /* ── reusable UI components ── */ function Section({ title, sub, right, accent, children }) { return ( -
+
{title}
@@ -97,7 +109,7 @@ function Ring({ value, max = 100, size = 120, stroke = 9, color = '#E5A653', sub return (
- + +
) @@ -146,6 +158,7 @@ export default function DashboardPage() { const [dash, setDash] = useState(null) // Start loading=false when the cache already has a dashboard entry — // we can hydrate from localStorage in the first paint and skip the spinner. + const { isTourActive, startTour, tourCompleted } = useTour() const [loading, setLoading] = useState(() => api.getCacheTimestamp('dashboard') == null) const [syncing, setSyncing] = useState(false) const [error, setError] = useState(null) @@ -162,6 +175,19 @@ export default function DashboardPage() { load({ force: false }) }, []) + useEffect(() => { + // Only auto-start for first-time users + if (!tourCompleted && !isTourActive) { + // Wait for page to fully render before starting tour + const timer = setTimeout(() => { + startTour() + }, 800) // 800ms delay allows all components to load + + // Cleanup timer if component unmounts + return () => clearTimeout(timer) + } + }, [tourCompleted, isTourActive, startTour]) + /** * Load dashboard data. * force=false → cache-first (instant if data is already stored) @@ -284,8 +310,33 @@ export default function DashboardPage() { if (!loading && linked.length === 0) return (
+
+
🚀
@@ -293,7 +344,7 @@ export default function DashboardPage() {

Link your coding platforms to unlock your performance intelligence dashboard.

@@ -304,7 +355,7 @@ export default function DashboardPage() { ) return ( -
+
@@ -342,7 +393,7 @@ export default function DashboardPage() {

{consistency.currentStreak > 0 - ? <>{consistency.currentStreak}-day streak 🔥 · Best: {consistency.longestStreak}d · {totalSolved} problems solved + ? <>{consistency.currentStreak}-day streak 🔥 · Best: {consistency.longestStreak}d · {totalSolved} problems solved : `${totalSolved} problems solved. ${recommendation[0]?.action || 'Keep grinding!'}`}

{/* rank progress bar */} @@ -357,7 +408,7 @@ export default function DashboardPage() { Progress to {tier.next} {perfScore}/{tier.max}
-
+
@@ -385,11 +436,11 @@ export default function DashboardPage() { {/* ══ KPI CARDS ══ */}
{[ - { icon: '✅', val: totalSolved, lbl: 'Total Solved', sub: `${easySolved}E · ${mediumSolved}M · ${hardSolved}H`, color: '#E5A653', pct: Math.min((totalSolved / 300) * 100, 100), trend: momentum.delta }, - { icon: '🎯', val: `${accRate}%`, lbl: 'Acceptance Rate', sub: `${accepted} accepted of ${subs.length} recent`, color: '#22C55E', pct: accRate }, + { icon: '✅', val: totalSolved, lbl: 'Total Solved', sub: `${easySolved}E · ${mediumSolved}M · ${hardSolved}H`, color: 'var(--emphasis-color)', pct: Math.min((totalSolved / 300) * 100, 100), trend: momentum.delta }, + { icon: '🎯', val: `${accRate}%`, lbl: 'Acceptance Rate', sub: `${accepted} accepted of ${subs.length} recent`, color: 'var(--difficulty-easy)', pct: accRate }, { icon: '⚡', val: efficiency.score, lbl: 'Efficiency Score', sub: `${efficiency.firstAttemptRate}% first-try (${efficiency.sampleSize} recent)`, color: '#38BDF8', pct: efficiency.score }, - { icon: '📅', val: consistency.score, lbl: 'Consistency', sub: `${consistency.activeDays} active / last 30 days`, color: '#9F8FE3', pct: consistency.score }, - { icon: '📈', val: avgPerWeek, lbl: 'Avg / Week', sub: `Best: ${bestWeek} · This week: ${momentum.thisWeek}`, color: '#F59E0B', pct: bestWeek ? (momentum.thisWeek / bestWeek) * 100 : 0, trend: momentum.delta }, + { icon: '📅', val: consistency.score, lbl: 'Consistency', sub: `${consistency.activeDays} active / last 30 days`, color: 'var(--lavender)', pct: consistency.score }, + { icon: '📈', val: avgPerWeek, lbl: 'Avg / Week', sub: `Best: ${bestWeek} · This week: ${momentum.thisWeek}`, color: 'var(--difficulty-medium)', pct: bestWeek ? (momentum.thisWeek / bestWeek) * 100 : 0, trend: momentum.delta }, ].map((s, i) => (
{ - const quality = d === 'Hard' && perWeek >= 1 ? { label: 'Strong', color: '#22C55E' } - : d === 'Medium' && perWeek >= 2 ? { label: 'On track', color: '#22C55E' } - : perWeek === 0 ? { label: 'None', color: '#64748B' } + const quality = d === 'Hard' && perWeek >= 1 ? { label: 'Strong', color: 'var(--difficulty-easy)' } + : d === 'Medium' && perWeek >= 2 ? { label: 'On track', color: 'var(--difficulty-easy)' } + : perWeek === 0 ? { label: 'None', color: 'var(--text-muted)' } : null return (
@@ -450,11 +501,11 @@ export default function DashboardPage() {
-
#{i + 1}
+
#{i + 1}
{t.topic} {t.isHighPriority && }
- {t.count} + {t.count}
{/* Why it's flagged */}
@@ -470,8 +521,8 @@ export default function DashboardPage() { {t.count} solved target: {t.target}
-
-
+
+
@@ -674,15 +725,15 @@ export default function DashboardPage() {
-
✓ Strengths
+
✓ Strengths
{contest.strengths.length - ? contest.strengths.map((s, i) =>
{s}
) + ? contest.strengths.map((s, i) =>
{s}
) :
Keep solving to build strengths
}
-
✗ To Improve
+
✗ To Improve
{contest.weaknesses.length - ? contest.weaknesses.map((w, i) =>
{w}
) + ? contest.weaknesses.map((w, i) =>
{w}
) :
No major gaps found!
}
@@ -703,9 +754,9 @@ export default function DashboardPage() {
Milestone Projections
{prediction.milestones.map((m, i) => ( -
+
{m.target}
-
+
@@ -720,7 +771,7 @@ export default function DashboardPage() {
{linked.map(lp => { - const m = PMETA[lp.platform] || { label: lp.platform, color: '#E5A653', icon: '🔷' } + const m = PMETA[lp.platform] || { label: lp.platform, color: 'var(--emphasis-color)', icon: '🔷' } const ps = platforms.find(p => p.platform === lp.platform) || {} const sv = ps.totalSolved || 0 const e = ps.easySolved || 0 @@ -748,7 +799,7 @@ export default function DashboardPage() { {l} {v} ({tot ? Math.round(v / tot * 100) : 0}%)
-
+
@@ -789,14 +840,14 @@ export default function DashboardPage() {
{t.topic} - {t.isHighPriority && } + {t.isHighPriority && }
{t.count} solved
-
+
@@ -850,9 +901,9 @@ export default function DashboardPage() { sub="Recent submissions — status from LeetCode API (titleSlug + status)" right={
- {accepted} AC - {subs.length - accepted} non-AC - {accRate}% + {accepted} AC + {subs.length - accepted} non-AC + {accRate}%
}>
@@ -869,9 +920,9 @@ export default function DashboardPage() {
{ e.currentTarget.style.background = 'rgba(255,255,255,.04)'; e.currentTarget.style.transform = 'translateX(3px)' }} - onMouseLeave={e => { e.currentTarget.style.background = 'rgba(255,255,255,.022)'; e.currentTarget.style.transform = 'translateX(0)' }}> + style={{ background: 'var(--surface-glass)', border: `1px solid ${ok ? 'rgba(34,197,94,.13)' : 'rgba(239,68,68,.1)'}`, borderRadius: 11, padding: '10px 14px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', transition: 'all .2s', cursor: 'default' }} + onMouseEnter={e => { e.currentTarget.style.background = 'var(--xp-track-bg)'; e.currentTarget.style.transform = 'translateX(3px)' }} + onMouseLeave={e => { e.currentTarget.style.background = 'var(--surface-glass)'; e.currentTarget.style.transform = 'translateX(0)' }}>
{ok ? '✓' : '✗'} @@ -909,21 +960,21 @@ export default function DashboardPage() { })()}>
{[ - { title: 'First Blood', desc: 'Solve 1 problem', cur: totalSolved, tgt: 1, icon: '🩸', color: '#EF4444' }, - { title: 'Warm Up', desc: 'Solve 10 problems', cur: totalSolved, tgt: 10, icon: '🔥', color: '#F59E0B' }, - { title: 'Half Century', desc: 'Solve 50 problems', cur: totalSolved, tgt: 50, icon: '🌟', color: '#9F8FE3' }, + { title: 'First Blood', desc: 'Solve 1 problem', cur: totalSolved, tgt: 1, icon: '🩸', color: 'var(--difficulty-hard)' }, + { title: 'Warm Up', desc: 'Solve 10 problems', cur: totalSolved, tgt: 10, icon: '🔥', color: 'var(--difficulty-medium)' }, + { title: 'Half Century', desc: 'Solve 50 problems', cur: totalSolved, tgt: 50, icon: '🌟', color: 'var(--lavender)' }, { title: 'Century', desc: 'Solve 100 problems', cur: totalSolved, tgt: 100, icon: '👑', color: '#38BDF8' }, - { title: 'Grinder', desc: 'Solve 200 problems', cur: totalSolved, tgt: 200, icon: '⚡', color: '#E5A653' }, - { title: 'Streak Master', desc: '14-day streak', cur: consistency.longestStreak, tgt: 14, icon: '🔥', color: '#10B981' }, - { title: 'Efficiency Pro', desc: 'Efficiency ≥ 70', cur: efficiency.score, tgt: 70, icon: '🎯', color: '#F59E0B' }, - { title: 'Contest Ready', desc: 'Contest score ≥ 60', cur: contest.score, tgt: 60, icon: '🏆', color: '#22C55E' }, + { title: 'Grinder', desc: 'Solve 200 problems', cur: totalSolved, tgt: 200, icon: '⚡', color: 'var(--emphasis-color)' }, + { title: 'Streak Master', desc: '14-day streak', cur: consistency.longestStreak, tgt: 14, icon: '🔥', color: 'var(--difficulty-easy)' }, + { title: 'Efficiency Pro', desc: 'Efficiency ≥ 70', cur: efficiency.score, tgt: 70, icon: '🎯', color: 'var(--difficulty-medium)' }, + { title: 'Contest Ready', desc: 'Contest score ≥ 60', cur: contest.score, tgt: 60, icon: '🏆', color: 'var(--difficulty-easy)' }, { title: 'Topic Explorer', desc: '8+ topics practised', cur: topicStats.length, tgt: 8, icon: '🗺️', color: '#E879F9' }, ].map((m, i) => { const pct = Math.min((m.cur / m.tgt) * 100, 100) const done = pct >= 100 return (
{ e.currentTarget.style.transform = 'translateY(-2px)'; e.currentTarget.style.opacity = '1' }} onMouseLeave={e => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.opacity = done ? '1' : '0.62' }}>
{m.icon}
@@ -933,7 +984,7 @@ export default function DashboardPage() { {Math.min(m.cur, m.tgt)}/{m.tgt}
{m.desc}
-
+
diff --git a/frontend/website/src/pages/LandingPage.jsx b/frontend/website/src/pages/LandingPage.jsx index 3f4aa84..d3c0e78 100644 --- a/frontend/website/src/pages/LandingPage.jsx +++ b/frontend/website/src/pages/LandingPage.jsx @@ -1,9 +1,8 @@ -import { useNavigate } from 'react-router-dom' -import { useEffect, useRef, useState } from 'react' -import { Link } from "react-router-dom"; +import { useEffect, useRef, useState } from 'react'; import toast from "react-hot-toast"; -import Navbar from '../components/Navbar'; +import { useNavigate } from 'react-router-dom'; import Footer from '../components/Footer'; +import Navbar from '../components/Navbar'; /* * Landing page — "midnight library" studygram. @@ -1191,11 +1190,11 @@ const ML_CSS = ` -webkit-backdrop-filter: blur(22px); border: 1px solid var(--edge); border-radius: 18px; - box-shadow: 0 14px 40px rgba(0,0,0,0.3), inset 0 1px 0 rgba(237,228,206,0.04); - transition: box-shadow 0.3s, border-color 0.3s; + box-shadow: none; + transition: border-color 0.3s; } .ml-nav-scrolled { - box-shadow: 0 20px 60px rgba(0,0,0,0.55), inset 0 1px 0 rgba(237,228,206,0.05); + box-shadow: none; border-color: rgba(229,166,83,0.35); } .ml-logo { display: flex; align-items: center; gap: 10px; } @@ -1650,7 +1649,7 @@ const ML_CSS = ` overflow: hidden; box-shadow: 0 40px 90px rgba(0,0,0,0.6), - 0 0 0 1px rgba(255,255,255,0.02) inset, + 0 0 0 1px var(--surface-inset) inset, 0 0 80px rgba(229,166,83,0.08); backdrop-filter: blur(8px); } @@ -1729,8 +1728,8 @@ const ML_CSS = ` background: repeating-linear-gradient( 180deg, - rgba(255,255,255,0.008) 0, - rgba(255,255,255,0.008) 22px, + var(--surface-border-subtle) 0, + var(--surface-border-subtle) 22px, transparent 22px, transparent 44px ), @@ -1898,7 +1897,7 @@ const ML_CSS = ` position: absolute; top: 6px; left: 20%; width: 28%; height: 7px; border-radius: 50%; - background: rgba(255,255,255,0.15); + background: var(--surface-glow); filter: blur(2.5px); z-index: 2; } @@ -1992,7 +1991,7 @@ const ML_CSS = ` border: 1px solid color-mix(in srgb, var(--s-accent) 55%, transparent); box-shadow: 0 2px 6px rgba(0,0,0,0.45), - 0 0 0 1px rgba(255,255,255,0.03) inset, + 0 0 0 1px var(--surface-inset) inset, 0 -1px 0 color-mix(in srgb, var(--s-accent) 22%, transparent) inset; transition: opacity 0.45s ease, @@ -2007,7 +2006,7 @@ const ML_CSS = ` .ml-fstack-items .ml-fstack-item:not([data-taken="true"]):first-of-type { box-shadow: 0 2px 10px rgba(0,0,0,0.5), - 0 0 0 1px rgba(255,255,255,0.05) inset, + 0 0 0 1px var(--surface-inset) inset, 0 0 18px color-mix(in srgb, var(--s-accent) 35%, transparent); } @@ -2196,7 +2195,7 @@ const ML_CSS = ` transform: translateX(-50%) rotate(-3deg); width: 82px; height: 20px; background-image: repeating-linear-gradient( - 135deg, transparent 0 6px, rgba(255,255,255,0.12) 6px 12px + 135deg, transparent 0 6px, var(--surface-border-subtle) 6px 12px ); background-color: var(--tape); opacity: 0.38; @@ -2634,4 +2633,322 @@ const ML_CSS = ` .ml-eyebrow-dot { animation: none; } .ml-mock-bar-fill { animation: none; } } -` + +/* ── Light theme adjustments ── */ +[data-theme="light"] .ml-grid-overlay { + background-image: + linear-gradient(rgba(15, 23, 42, 0.04) 1px, transparent 1px), + linear-gradient(90deg, rgba(15, 23, 42, 0.04) 1px, transparent 1px); +} +[data-theme="light"] .ml-vignette { + background: radial-gradient(ellipse at top, transparent 0%, transparent 50%, rgba(15, 23, 42, 0.06) 100%); +} +[data-theme="light"] .ml-orb-1 { + background: radial-gradient(circle, rgba(159, 143, 227, 0.14), transparent 70%); +} +[data-theme="light"] .ml-orb-2 { + background: radial-gradient(circle, rgba(229, 166, 83, 0.12), transparent 70%); +} +[data-theme="light"] .ml-orb-3 { + background: radial-gradient(circle, rgba(136, 192, 163, 0.1), transparent 70%); +} +[data-theme="light"] .ml-mock-window, +[data-theme="light"] .ml-mock-card, +[data-theme="light"] .ml-feature-card, +[data-theme="light"] .ml-pricing-card { + box-shadow: 0 8px 32px rgba(15, 23, 42, 0.08); +} + +[data-theme="light"] .ml-stack-card { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(241, 245, 249, 0.98)); + border: 1px solid rgba(15, 23, 42, 0.12); + box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08); + color: var(--ink); +} + +[data-theme="light"] .ml-stack-card[data-stack-in="true"] { + box-shadow: + 0 18px 40px rgba(15, 23, 42, 0.12), + 0 0 0 1px rgba(15, 23, 42, 0.08) inset; +} + +[data-theme="light"] .ml-stack-card .ml-fcard-desc { + color: var(--ink-mute); +} + +[data-theme="light"] .ml-mock, +[data-theme="light"] .ml-placement-card, +[data-theme="light"] .ml-faq-open, +[data-theme="light"] .ml-mock-card, +[data-theme="light"] .ml-feature-card, +[data-theme="light"] .ml-pricing-card { + background: var(--bg-card); + border-color: var(--border-subtle); + box-shadow: 0 14px 36px rgba(15, 23, 42, 0.08); + color: var(--ink); +} + +[data-theme="light"] .ml-mock { + box-shadow: + 0 30px 80px rgba(15, 23, 42, 0.08), + inset 0 1px 0 rgba(255, 255, 255, 0.85); +} + +[data-theme="light"] .ml-mock-header { + background: rgba(15, 23, 42, 0.05); + border-color: rgba(15, 23, 42, 0.08); +} + +[data-theme="light"] .ml-term-window { + background: linear-gradient(180deg, rgba(248, 250, 252, 1), rgba(255, 255, 255, 1)); + border-color: rgba(15, 23, 42, 0.12); + box-shadow: + 0 40px 90px rgba(15, 23, 42, 0.08), + inset 0 1px 0 rgba(255, 255, 255, 0.9), + 0 0 80px rgba(229, 166, 83, 0.08); +} + +[data-theme="light"] .ml-term-bar { + background: linear-gradient(180deg, rgba(248, 250, 252, 0.95), rgba(241, 245, 249, 0.96)); + border-color: rgba(15, 23, 42, 0.08); +} + +[data-theme="light"] .ml-term-body { + color: var(--ink); + background: rgba(255, 255, 255, 0.94); +} + +[data-theme="light"] .ml-term-run { + color: var(--amber); + background: rgba(229, 166, 83, 0.12); + border-color: rgba(229, 166, 83, 0.22); +} + +[data-theme="light"] .ml-term-run-done { + color: var(--sage); + background: rgba(136, 192, 163, 0.14); + border-color: rgba(136, 192, 163, 0.28); +} + +[data-theme="light"] .ml-faq-q { + background: rgba(15, 23, 42, 0.04); + color: var(--ink); +} + +[data-theme="light"] .ml-mock-stat { + background: var(--bg-soft); + border-color: rgba(15, 23, 42, 0.08); +} + +[data-theme="light"] .ml-mock-bar-track { + background: rgba(15, 23, 42, 0.05); +} + +[data-theme="light"] .ml-brand { + background: var(--bg-soft); + border-color: rgba(15, 23, 42, 0.08); +} + +[data-theme="light"] .ml-step, +[data-theme="light"] .ml-pcard, +[data-theme="light"] .ml-placement-card, +[data-theme="light"] .ml-faq-open { + background: var(--bg-card); + border-color: var(--border-subtle); + box-shadow: 0 14px 36px rgba(15, 23, 42, 0.08); + color: var(--ink); +} + +[data-theme="light"] .ml-step:hover, +[data-theme="light"] .ml-pcard:hover, +[data-theme="light"] .ml-placement-card:hover { + box-shadow: 0 22px 60px rgba(15, 23, 42, 0.12); +} + +[data-theme="light"] .ml-faq-q { + background: rgba(15, 23, 42, 0.04); + color: var(--ink); +} + +[data-theme="light"] .ml-faq-q:hover { + background: rgba(15, 23, 42, 0.08); +} + +[data-theme="light"] .ml-mock, +[data-theme="light"] .ml-stack-card, +[data-theme="light"] .ml-mock-card, +[data-theme="light"] .ml-feature-card, +[data-theme="light"] .ml-pricing-card { + background: var(--bg-card); + border-color: var(--border-subtle); + box-shadow: 0 14px 36px rgba(15, 23, 42, 0.08); + color: var(--ink); +} + +[data-theme="light"] .ml-mock-header { + background: rgba(15, 23, 42, 0.05); + border-color: rgba(15, 23, 42, 0.08); +} + +[data-theme="light"] .ml-term-window { + background: linear-gradient(180deg, rgba(248, 250, 252, 1), rgba(255, 255, 255, 1)); + border-color: rgba(15, 23, 42, 0.12); + box-shadow: + 0 40px 90px rgba(15, 23, 42, 0.08), + inset 0 1px 0 rgba(255, 255, 255, 0.9), + 0 0 80px rgba(229, 166, 83, 0.08); +} + +[data-theme="light"] .ml-term-bar { + background: linear-gradient(180deg, rgba(248, 250, 252, 0.95), rgba(241, 245, 249, 0.96)); + border-color: rgba(15, 23, 42, 0.08); +} + +[data-theme="light"] .ml-term-body { + color: var(--ink); + background: rgba(255, 255, 255, 0.94); +} + +[data-theme="light"] .ml-term-run { + color: var(--amber); + background: rgba(229, 166, 83, 0.12); + border-color: rgba(229, 166, 83, 0.22); +} + +[data-theme="light"] .ml-term-run-done { + color: var(--sage); + background: rgba(136, 192, 163, 0.14); + border-color: rgba(136, 192, 163, 0.28); +} + +[data-theme="light"] .ml-fstack-body { + background: linear-gradient( + 90deg, + rgba(248, 250, 252, 0.95) 0%, + rgba(241, 245, 249, 0.95) 100% + ); + border-left-color: rgba(15, 23, 42, 0.12); + border-right-color: rgba(15, 23, 42, 0.12); +} + +[data-theme="light"] .ml-fstack-mouth { + background: radial-gradient( + ellipse at 42% 35%, + rgba(229, 229, 229, 0.96) 0%, + rgba(241, 245, 249, 0.98) 65% + ); + border-color: rgba(15, 23, 42, 0.12); + box-shadow: + 0 -6px 22px rgba(229, 166, 83, 0.12), + inset 0 5px 16px rgba(15, 23, 42, 0.08), + inset 0 -2px 8px rgba(229, 166, 83, 0.08); +} + +[data-theme="light"] .ml-fstack-mouth-inner { + background: radial-gradient( + ellipse at center, + rgba(255, 255, 255, 0.96) 0%, + rgba(241, 245, 249, 0.95) 70% + ); + box-shadow: inset 0 4px 14px rgba(15, 23, 42, 0.12); +} + +[data-theme="light"] .ml-fstack-item { + background: linear-gradient( + 90deg, + color-mix(in srgb, var(--s-accent) 32%, rgba(15, 23, 42, 0.12)) 0%, + color-mix(in srgb, var(--s-accent) 14%, rgba(15, 23, 42, 0.10)) 60%, + color-mix(in srgb, var(--s-accent) 32%, rgba(15, 23, 42, 0.12)) 100% + ); + box-shadow: + 0 2px 6px rgba(15, 23, 42, 0.12), + 0 0 0 1px rgba(255, 255, 255, 0.35) inset, + 0 -1px 0 color-mix(in srgb, var(--s-accent) 22%, transparent) inset; +} + +[data-theme="light"] .ml-fstack-item-num { + background: rgba(15, 23, 42, 0.08); + color: var(--ink); +} + +[data-theme="light"] .ml-fstack-base { + background: linear-gradient( + 180deg, + rgba(248, 250, 252, 0.95) 0%, + rgba(241, 245, 249, 0.95) 100% + ); + border-color: rgba(15, 23, 42, 0.12); + box-shadow: + 0 14px 38px rgba(15, 23, 42, 0.12), + inset 0 -3px 10px rgba(15, 23, 42, 0.10); +} + +[data-theme="light"] .ml-fstack-top-arrow, +[data-theme="light"] .ml-fstack-top-arrow-tip { + color: var(--amber); + text-shadow: none; +} + +[data-theme="light"] .ml-fstack-mouth { + background: radial-gradient( + ellipse at 42% 35%, + rgba(241, 245, 249, 0.96) 0%, + rgba(226, 232, 240, 0.98) 65% + ); + border-color: rgba(15, 23, 42, 0.12); + box-shadow: + 0 -6px 22px rgba(229, 166, 83, 0.12), + inset 0 5px 16px rgba(15, 23, 42, 0.08), + inset 0 -2px 8px rgba(229, 166, 83, 0.08); +} + +[data-theme="light"] .ml-fstack-mouth-inner { + background: radial-gradient( + ellipse at center, + rgba(255, 255, 255, 0.96) 0%, + rgba(241, 245, 249, 0.95) 70% + ); + box-shadow: inset 0 4px 14px rgba(15, 23, 42, 0.12); +} + +[data-theme="light"] .ml-fstack-body { + background: linear-gradient( + 90deg, + rgba(248, 250, 252, 0.95) 0%, + rgba(241, 245, 249, 0.95) 100% + ); + border-left-color: rgba(15, 23, 42, 0.12); + border-right-color: rgba(15, 23, 42, 0.12); +} + +[data-theme="light"] .ml-fstack-item { + background: linear-gradient( + 90deg, + rgba(255, 255, 255, 0.96) 0%, + rgba(241, 245, 249, 0.92) 40%, + rgba(255, 255, 255, 0.96) 100% + ); + border: 1px solid rgba(15, 23, 42, 0.08); + box-shadow: + 0 2px 6px rgba(15, 23, 42, 0.12), + 0 0 0 1px rgba(255, 255, 255, 0.35) inset; +} + +[data-theme="light"] .ml-fstack-item-num { + background: rgba(15, 23, 42, 0.08); + color: var(--ink); +} + +[data-theme="light"] .ml-fstack-base { + background: linear-gradient( + 180deg, + rgba(248, 250, 252, 0.95) 0%, + rgba(241, 245, 249, 0.95) 100% + ); + border-color: rgba(15, 23, 42, 0.12); + box-shadow: + 0 14px 38px rgba(15, 23, 42, 0.12), + inset 0 -3px 10px rgba(15, 23, 42, 0.10); +} + +`; diff --git a/frontend/website/src/pages/LoginPage.jsx b/frontend/website/src/pages/LoginPage.jsx index d64843f..9dc39b0 100644 --- a/frontend/website/src/pages/LoginPage.jsx +++ b/frontend/website/src/pages/LoginPage.jsx @@ -64,15 +64,15 @@ export default function LoginPage() { marginBottom: '16px', fontSize: '13px', lineHeight: 1.55, - color: '#c8943a', + color: 'var(--text-accent)', display: 'flex', gap: 9, alignItems: 'flex-start', }}> - Heads up! Our backend runs on a free tier and may be - sleeping. The first request can take 10–30 seconds to + Heads up! Our backend runs on a free tier and may be + sleeping. The first request can take 10–30 seconds to wake up — please be patient. Once it's up, everything runs smoothly. ☕
diff --git a/frontend/website/src/pages/OnboardingPage.jsx b/frontend/website/src/pages/OnboardingPage.jsx index 71ea602..d9fc137 100644 --- a/frontend/website/src/pages/OnboardingPage.jsx +++ b/frontend/website/src/pages/OnboardingPage.jsx @@ -1,20 +1,15 @@ /** - * OnboardingPage.jsx - * ────────────────── + * OnboardingPage.jsx (Updated) + * ────────────────────────────── * Multi-step onboarding with proof-of-ownership verification. - * - * The "prove you own the account" challenge is back: the user types their - * handle, clicks Link, the backend picks a target problem (LeetCode: Two Sum, - * Codeforces: 4A Watermelon) and records startTime. The user submits that - * problem on the real platform, clicks Check, and the backend scans their - * recent submissions for an Accepted entry after startTime. Verified only - * then. All of this now runs against the main backend (port 8080) — the - * old separate-service-on-port-4000 dependency is gone. + * Now integrated with the tour feature - tour will start automatically + * after onboarding completes on the dashboard. */ -import { useState, useEffect } from 'react' +import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' import * as api from '../services/api' +import { useTour } from '../tour/TourContext' const STEPS = ['Skill Level', 'Platforms', 'Companies'] @@ -37,6 +32,7 @@ const PLATFORM_CONFIG = [ export default function OnboardingPage() { const navigate = useNavigate() + const { startTour } = useTour() const [step, setStep] = useState(0) const [skill, setSkill] = useState(null) const [selectedCompanies, setSelectedCompanies] = useState([]) @@ -156,210 +152,235 @@ export default function OnboardingPage() { }) } + // ── Finish onboarding and redirect to dashboard + // The tour will start automatically on the dashboard via useEffect const handleFinish = async () => { - // Run every linkPlatform call in parallel — they're independent server-side - // and the old sequential loop was adding whole seconds on accounts with - // multiple verified platforms (each POST triggers a backend sync). - const linkJobs = Object.entries(platformState) - .filter(([, ps]) => ps.status === 'verified' && ps.username.trim()) - .map(([key, ps]) => - api.linkPlatform(key, ps.username.trim()) - .catch(err => { console.error(`Failed to link ${key}:`, err); return null }) - ) - await Promise.all(linkJobs) - - // Any cached dashboard data from before linking is now stale (new - // platforms just got attached). Wipe it so the dashboard mounts - // with a fresh fetch exactly once, then caches. - try { api.invalidateDashboardCache() } catch { /* ignore */ } - - navigate('/dashboard') + try { + // Save onboarding data to backend + await api.completeOnboarding({ + skillLevel: skill, + selectedCompanies, + platforms: Object.keys(platformState).reduce((acc, key) => { + if (platformState[key].status === 'verified') { + acc[key] = { + username: platformState[key].username, + verified: true, + } + } + return acc + }, {}), + }) + + // Mark tour as not completed so it starts fresh + localStorage.removeItem('algoSprint_tourCompleted') + + // Navigate to dashboard - tour will start there + navigate('/dashboard') + + // Start tour on next tick to ensure component mounted + setTimeout(() => { + startTour() + }, 500) + } catch (err) { + console.error('Error completing onboarding:', err) + } } return ( -
-
- {/* Step indicator */} -
-
- {STEPS.map((label, i) => ( -
-
-
- {i < step ? '✓' : i + 1} -
- {label} -
- {i < STEPS.length - 1 && ( -
- )} -
- ))} -
+
+
+ + {/* Header */} +
+
🚀
+

+ Let's Get Started +

+

+ Tell us about yourself so we can personalize your experience +

+
+ + {/* Progress indicator */} +
+ {STEPS.map((s, i) => ( +
+ ))}
- {/* Step 0 — Skill Level */} + {/* Step content */} {step === 0 && ( <> -

What's your current level?

-

We'll personalize your experience based on where you are now.

-
+

+ What's your skill level? +

+
{SKILLS.map(s => ( -
setSkill(s.id)} - id={`skill-${s.id}`} + style={{ + padding: '16px', + border: skill === s.id ? '2px solid var(--primary-color)' : '1px solid var(--border)', + background: skill === s.id ? 'rgba(59, 130, 246, 0.1)' : 'var(--bg-primary)', + borderRadius: '10px', + cursor: 'pointer', + textAlign: 'left', + transition: 'all 0.2s ease', + }} + onMouseEnter={(e) => { + e.currentTarget.style.background = e.currentTarget.style.background === 'rgba(59, 130, 246, 0.1)' ? 'rgba(59, 130, 246, 0.1)' : 'var(--bg-tertiary)' + }} + onMouseLeave={(e) => { + e.currentTarget.style.background = skill === s.id ? 'rgba(59, 130, 246, 0.1)' : 'var(--bg-primary)' + }} > -
{s.icon}
-
{s.name}
-
{s.desc}
-
+
{s.icon}
+
{s.name}
+
{s.desc}
+ ))}
)} - {/* Step 1 — Platforms with Submission-based Verification */} {step === 1 && ( <> -

Verify your platforms

-

Prove account ownership by solving a quick problem. We'll check your recent submissions.

- -
+

+ Connect your accounts +

+

+ Verify at least one platform to continue. We'll sync your problem history. +

+
{PLATFORM_CONFIG.map(p => { const ps = platformState[p.key] return ( -
- {/* Header */} -
- {p.label} - {ps.status === 'verified' && ( - ✓ Verified - )} - {(ps.status === 'pending' || ps.status === 'checking') && ( - ⏳ Awaiting submission - )} -
- - {/* Idle: username input + Link button */} +
+ + {ps.status === 'idle' && (
updatePlatform(p.key, { username: e.target.value })} - onKeyDown={e => { if (e.key === 'Enter') handleStart(p.key) }} - style={{ flex: 1 }} - disabled={ps.loading} - autoComplete="off" - autoCapitalize="off" - spellCheck={false} + style={{ + flex: 1, + padding: '10px 12px', + border: '1px solid var(--border)', + borderRadius: '8px', + background: 'var(--bg-primary)', + color: 'var(--text-primary)', + fontSize: 13, + }} />
)} - {/* Pending/checking: show the target problem + the Check button */} {(ps.status === 'pending' || ps.status === 'checking') && ps.problemUrl && ( -
-
-
- Prove you own @{ps.username}: -
-
- 1. Open the problem below and submit anything — even a wrong answer is fine -
-
- 2. Come back and hit Check Submission -
- - 🔗 {ps.problemName || 'Open Problem'} - -
- only submissions after you clicked Link count — we record the timestamp server-side -
-
- -
- - -
-
- )} - - {/* Verified */} - {ps.status === 'verified' && ( -
-
- Verified as @{ps.username} +
+
+ Verify your account by solving this problem:
+ + {ps.problemName} +
)} - {/* Error / info messages */} - {ps.message && ( + {ps.status === 'verified' && (
+ ✅ Verified as @{ps.username} +
+ )} + + {ps.message && ( +
{ps.message}
)} @@ -367,64 +388,93 @@ export default function OnboardingPage() { ) })}
- -

- ✓ Verify at least one platform to continue -

)} - {/* Step 2 — Companies */} {step === 2 && ( <> -

Target companies

-

Select companies you're targeting. We'll tailor problem recommendations accordingly.

-
+

+ Target companies +

+

+ Select companies you're targeting. We'll tailor recommendations accordingly. +

+
{COMPANIES.map(c => ( -
toggleCompany(c)} + style={{ + padding: '12px', + border: selectedCompanies.includes(c) ? '2px solid var(--primary-color)' : '1px solid var(--border)', + background: selectedCompanies.includes(c) ? 'rgba(59, 130, 246, 0.1)' : 'var(--bg-primary)', + borderRadius: '8px', + color: 'var(--text-primary)', + fontWeight: 600, + fontSize: 13, + cursor: 'pointer', + transition: 'all 0.2s ease', + }} + onMouseEnter={(e) => { + if (!selectedCompanies.includes(c)) { + e.currentTarget.style.background = 'var(--bg-tertiary)' + } + }} + onMouseLeave={(e) => { + e.currentTarget.style.background = selectedCompanies.includes(c) ? 'rgba(59, 130, 246, 0.1)' : 'var(--bg-primary)' + }} > {c} -
+ ))}
)} - {/* Navigation */} -
- {step > 0 ? ( - - ) :
} - - {step < STEPS.length - 1 ? ( - - ) : ( - - )} + {/* Navigation buttons */} +
+ + +
+
) -} +} \ No newline at end of file diff --git a/frontend/website/src/pages/PracticePage.jsx b/frontend/website/src/pages/PracticePage.jsx index b506b99..601af30 100644 --- a/frontend/website/src/pages/PracticePage.jsx +++ b/frontend/website/src/pages/PracticePage.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' import Sidebar from '../components/Sidebar' import Topbar from '../components/TopBar' @@ -44,7 +44,7 @@ function SkillRing({ pct, color, size = 44 }) { const r = (size - 6) / 2, circ = 2 * Math.PI * r, dash = (pct / 100) * circ return ( - + @@ -54,7 +54,7 @@ function SkillRing({ pct, color, size = 44 }) { function MiniBar({ pct, color }) { return ( -
+
) @@ -102,7 +102,7 @@ function MissionCard({ mission, onComplete, completing }) { {done ? '✓ Completed' : '🎯 Daily Mission'} {mission.sequence > 1 && ( - + #{mission.sequence} today )} @@ -172,8 +172,8 @@ function ProblemCard({ problem, category, featured = false }) {
{problem.reason && ( -
+
💡 {problem.reason}
)} @@ -230,7 +230,7 @@ function SkillPanel({ topics, diffStats, platforms }) {
{platforms?.length > 0 && ( -
+
Linked {platforms.map(p => { const meta = PLAT_META[p.toLowerCase()] || { color: '#E5A653', short: p, icon: '⬡' } @@ -240,7 +240,7 @@ function SkillPanel({ topics, diffStats, platforms }) { )} {diffStats && ( -
+
Progress
{[['Easy', diffStats.easyCount, 30, '#22C55E'], ['Medium', diffStats.mediumCount, 50, '#F59E0B'], ['Hard', diffStats.hardCount, 20, '#EF4444']].map(([label, val, target, color]) => { const pct = Math.min(Math.round(((val || 0) / target) * 100), 100) @@ -265,7 +265,7 @@ function SkillPanel({ topics, diffStats, platforms }) {
)} -
+
Weakest Topics
{sorted.length === 0 ?
Sync your platforms first
@@ -390,7 +390,7 @@ export default function PracticePage() { const rest = displayed.slice(1) return ( -
+
@@ -418,7 +418,7 @@ export default function PracticePage() { return {meta.icon} {meta.short} })}
-
+
{weakCount > 0 ? `${weakCount} weak spot${weakCount > 1 ? 's' : ''} detected · Easy, Medium and Hard problems ranked by impact` : 'All topics strong · here are your next progression problems'} @@ -426,12 +426,12 @@ export default function PracticePage() {
-
+
🔍 setSearch(e.target.value)} placeholder="Search problems…" - style={{ background:'transparent', border:'none', outline:'none', color:'#E2E8F0', fontSize:12, width:150 }} /> + style={{ background:'transparent', border:'none', outline:'none', color:'var(--text-primary)', fontSize:12, width:150 }} />
-
@@ -446,7 +446,7 @@ export default function PracticePage() { {loading ? (
-
Analysing your skill profile…
+
Analysing your skill profile…
Fetching problems from all your linked platforms
) : ( @@ -462,14 +462,14 @@ export default function PracticePage() { return (
{displayed.length === 0 ? ( -
+
🎉
All caught up!
-
No problems match this filter. Try "All Recs" or sync your platforms.
+
No problems match this filter. Try "All Recs" or sync your platforms.
-
+
@@ -68,7 +67,7 @@ export default function RecommendationsPage() { const rest = recs.slice(1) return ( -
+
@@ -100,7 +99,7 @@ export default function RecommendationsPage() {
{diff.nextMilestone}
-
+
{[['Easy', diff.easyCount, '#22C55E'], ['Medium', diff.mediumCount, '#F59E0B'], ['Hard', diff.hardCount, '#EF4444']].map(([l, v, c]) => (
{v}
@@ -114,9 +113,9 @@ export default function RecommendationsPage() { {/* ── Tabs ── */}
{[['daily', '🎯 Daily Picks'], ['weak', '⚠️ Weak Topics'], ['skill', '📊 Skill Map']].map(([k, l]) => ( - + ))} - +
{/* ══ DAILY TAB ══ */} @@ -141,7 +140,7 @@ export default function RecommendationsPage() { {featured.topic && }

{featured.title}

-
+
💡 {featured.reason}
@@ -149,7 +148,7 @@ export default function RecommendationsPage() { {featured.problemUrl ? ( Solve Now → ) : null} - +
@@ -189,7 +188,7 @@ export default function RecommendationsPage() { {w.topic} {w.pct}%
-
+
{w.solved} / {w.target} problems · {w.reason}
@@ -218,7 +217,7 @@ export default function RecommendationsPage() { {[['Easy', diff.easyCount, 30, '#22C55E'], ['Medium', diff.mediumCount, 60, '#F59E0B'], ['Hard', diff.hardCount, 20, '#EF4444']].map(([l, v, t, c]) => (
{l}{v}/{t}
-
+
diff --git a/frontend/website/src/pages/SignupPage.jsx b/frontend/website/src/pages/SignupPage.jsx index 00e336a..a344ce3 100644 --- a/frontend/website/src/pages/SignupPage.jsx +++ b/frontend/website/src/pages/SignupPage.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react' +import { useEffect, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' import * as api from '../services/api' @@ -171,15 +171,15 @@ export default function SignupPage() { marginBottom: '4px', fontSize: '13px', lineHeight: 1.55, - color: '#c8943a', + color: 'var(--text-accent)', display: 'flex', gap: 9, alignItems: 'flex-start', }}> - Heads up! Our backend runs on a free tier and may be - sleeping. The first request can take 10–30 seconds to + Heads up! Our backend runs on a free tier and may be + sleeping. The first request can take 10–30 seconds to wake up — please be patient. Once it's up, everything runs smoothly. ☕
diff --git a/frontend/website/src/services/api.js b/frontend/website/src/services/api.js index 3744329..8a63ae6 100755 --- a/frontend/website/src/services/api.js +++ b/frontend/website/src/services/api.js @@ -1,6 +1,6 @@ /** - * API Service Layer — Integrated with Spring Boot Backend (port 8080) - * Platform sync (LeetCode stats) is done server-side → stored in DB → served via /api/platforms/dashboard + * API Service Layer — Spring Boot Backend (port 8080) ONLY + * No mock API support - all calls go to real backend */ // Read from Vite's build-time env (`VITE_API_BASE`) on the deployed frontend; @@ -8,23 +8,11 @@ const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8080' // ── One-shot legacy-session cleanup ──────────────────────────────────────── -// -// Runs once when this module loads. Users who logged in before the JWT-cookie -// migration have a stale `jwt_token` value sitting in localStorage. The new -// code path doesn't send that token anymore, so those sessions can't auth — -// but the UI still thinks they're logged in (because their email is also in -// localStorage), so they end up staring at a half-loaded dashboard. -// -// Detect that situation here and wipe everything so the user lands on the -// login screen on the very next render. They sign in once, get the cookie, -// and from then on the new flow takes over. They don't have to clear caches -// or open DevTools — it just works. + ;(function migrateLegacyAuth() { if (typeof window === 'undefined') return try { if (localStorage.getItem('jwt_token') != null) { - // Wipe ALL persisted cache entries (any user prefix), too — the - // old session may have cached error envelopes against its email. try { const doomed = [] for (let i = 0; i < localStorage.length; i++) { @@ -42,24 +30,8 @@ const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8080' } catch { /* ignore — better to load app than crash on storage error */ } })() -/* ── Auth state — JWT lives in an HttpOnly cookie now ── - * - * SECURITY NOTE - * The JWT is no longer stored in localStorage. JavaScript on this page - * literally cannot read it, so a successful XSS attack can't steal the - * token and DevTools' Storage tab won't show it either. The browser - * sends the auth cookie automatically on every request thanks to - * `credentials: 'include'` below; we don't have to attach a Bearer - * header at all. - * - * The kept-in-localStorage values (email, name, username) are NOT - * credentials — they're just UI hints (the avatar's initial letter, - * routing decisions, etc.). Even if leaked, an attacker can't auth - * with them. The session itself lives entirely in the HttpOnly cookie. - */ +/* ── Auth state — JWT lives in an HttpOnly cookie now ── */ -// Backwards-compat shims — kept as no-ops so older code that imported -// these functions doesn't crash. Don't actually read or write tokens. export function getJWTToken() { return null } export function setJWTToken() { /* no-op: cookie is set by the server */ } @@ -71,33 +43,22 @@ export function setUsername(u) { if (u) localStorage.setItem('jwt_username', u); export function getUsername() { return localStorage.getItem('jwt_username') || '' } export function clearAuth() { - // Wipe dashboard cache FIRST — clearAllCache builds its prefix from - // the email, so we need the email still in storage at this point. try { clearAllCache() } catch { /* ignore */ } - // Best-effort: ask the server to clear the auth cookie. We don't await - // it — even if it fails (offline, server down), local UI state still - // gets cleared so the user sees themselves as logged out. try { fetch(`${API_BASE}/auth/logout`, { method: 'POST', credentials: 'include' }) .catch(() => {}) } catch { /* ignore */ } - // Strip any legacy JWT some older build may have left behind. localStorage.removeItem('jwt_token') localStorage.removeItem('jwt_email') localStorage.removeItem('jwt_name') localStorage.removeItem('jwt_username') localStorage.removeItem('algoledger_platforms') - // Profile pic cache — cleared via the shared helper so avatars update live. try { import('../utils/profilePic').then(m => m.clearProfilePic()).catch(() => {}) } catch { /* ignore */ } } export function isAuthenticated() { - // We can't read the HttpOnly cookie, so we use the email as a UI hint: - // if the user has logged in this browser, their email is in localStorage. - // Any actually-stale session gets caught by the 401 handler in - // authFetchJson, which clears local state and redirects to /login. return !!getUserEmail() } @@ -110,7 +71,7 @@ async function authFetch(path, options = {}) { return fetch(`${API_BASE}${path}`, { ...options, headers, - credentials: 'include', // send the auth cookie cross-origin + credentials: 'include', // send the auth cookie }) } @@ -119,7 +80,7 @@ export async function authFetchJson(path, options = {}) { try { const res = await authFetch(path, options) - // If token expired / invalid, backend now returns JSON 401 — clear session and redirect + // If token expired / invalid, backend returns JSON 401 if (res.status === 401) { const body = await res.text() let errMsg = 'Session expired. Please log in again.' @@ -129,7 +90,6 @@ export async function authFetchJson(path, options = {}) { return { ok: false, error: errMsg } } - // For non-2xx that still have a body, try to parse JSON const text = await res.text() if (!text) return { ok: false, error: `HTTP ${res.status} (empty response)` } let data @@ -170,7 +130,6 @@ export function savePlatformVerified(platform, username, verified, verifiedAt) { /** * Get full platform data including verification status. - * Returns { leetcode: { username, verified, verifiedAt }, ... } */ export function getLinkedPlatformsFull() { try { @@ -187,14 +146,7 @@ export function getLinkedPlatformsFull() { } catch { return {} } } -/* ── Platform ownership verification (main backend) ── - * - * Two-step proof: (1) start — get target problem + startTime, - * (2) check — after the user submits on the real platform, we scan their - * recent submissions for an Accepted submission to the target problem - * with timestamp >= startTime. Endpoints are authenticated (onboarding - * happens post-login anyway). - */ +/* ── Platform ownership verification ── */ /** Step 1 — confirm handle exists and receive the target problem. */ export async function verifyStart(platform, handle) { @@ -225,669 +177,250 @@ export async function verifyCheck(platform, handle, problemSlug, startTime) { if (r.ok && r.data.verified) return { success: true } return { success: false, - message: (r.data && r.data.message) || r.error || - "Couldn't find your submission yet. Try again after you submit.", + message: r.error || 'Verification failed', } } -/* ── Legacy shims for older callers. - * The demo-backend-on-port-4000 flow is gone; these forward to the - * main-backend verifyStart/verifyCheck with the same call signatures - * the old onboarding code expected. Safe to delete any time. */ -export async function initiateLeetCodeVerification(username) { - return verifyStart('leetcode', username) -} -export async function checkLeetCodeSubmission(username, startTime) { - return verifyCheck('leetcode', username, 'two-sum', startTime) -} -export async function initiateCodeforcesVerification(handle) { - return verifyStart('codeforces', handle) -} -export async function checkCodeforcesSubmission(handle, startTime) { - return verifyCheck('codeforces', handle, '4-A', startTime) -} - -/* ── Demo Backend — LeetCode Data API calls ── */ - -export async function deleteLeetCode(username) { - const res = await fetch(`${DEMO_API_BASE}/leetcode/delete-leetcode/${username}`, { method: 'DELETE' }) - return res.json() -} - -export async function fetchLeetCode(username) { - const res = await fetch(`${DEMO_API_BASE}/leetcode/fetch/${username}`) - return res.json() -} - -/* ── Demo Backend — Codeforces Data API calls ── */ - -export async function deleteCodeforces(handle) { - const res = await fetch(`${DEMO_API_BASE}/codeforces/delete/${handle}`, { method: 'DELETE' }) - return res.json() -} - -export async function fetchCodeforces(handle) { - const res = await fetch(`${DEMO_API_BASE}/codeforces/fetch/${handle}`) - return res.json() -} - -/* ── Authentication API calls ── */ - -/** Register a new user — legacy direct path, kept for backward-compat. */ -export async function register(name, email, password) { - const res = await fetch(`${API_BASE}/auth/addNewUser`, { +export async function completeOnboarding(data) { + const r = await authFetchJson('/api/onboarding/complete', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, email, password, roles: 'ROLE_USER' }), + body: JSON.stringify(data), }) - const data = await res.text() - if (res.ok && name) setUserName(name) - return { success: res.ok, data, status: res.status } + if (r.ok) return { success: true } + return { success: false, message: r.error || 'Failed to complete onboarding' } } -/* ── Email-verified signup (two-step) ── */ +/* ── Cache helpers for reducing API calls ── */ -/** - * Step 1 — send an OTP to the user's email (name + @username + email + password). - * - * If the backend has verification disabled (feature flag off for dev / - * pre-domain-verification), the response will include a JWT directly and - * the frontend should skip the OTP step. We detect that here and stash - * the auth tokens immediately, mirroring what signupVerify does. - */ -export async function signupRequest(name, username, email, password) { - try { - const res = await fetch(`${API_BASE}/auth/signup/request`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, username, email, password }), - credentials: 'include', // accept the auth cookie if backend skips OTP - }) - const data = await res.json().catch(() => ({})) - if (res.ok) { - // Verification-disabled fast-path: server already created the - // account and dropped the auth cookie. Mirror the non-secret UI - // bits into localStorage; no token here on purpose. - if (data.verificationSkipped) { - setUserEmail(data.email || email) - if (data.name) setUserName(data.name) - if (data.username) setUsername(data.username) - } - return { ok: true, data } - } - return { ok: false, error: data.error || 'Could not send verification code' } - } catch (e) { - return { ok: false, error: 'Cannot connect to server. Please try again.' } - } +function getCacheKey(email, endpoint) { + return `algoledger:cache:${email}:${endpoint}` } -/** Live check: is this @username free to grab? (public, no auth required) */ -export async function checkUsernameAvailable(u) { +export function clearAllCache() { + const email = getUserEmail() + if (!email) return try { - const res = await fetch(`${API_BASE}/auth/username/check?u=${encodeURIComponent(u)}`) - if (res.ok) return res.json() - return { available: false, reason: 'Couldn\'t check right now' } - } catch (e) { - return { available: false, reason: 'Couldn\'t check right now' } - } -} - -/** Change the logged-in user's @username. */ -export async function updateMyUsername(username) { - return authFetchJson('/auth/me/username', { - method: 'PUT', - body: JSON.stringify({ username }), - }) + const doomed = [] + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i) + if (k && k.startsWith(`algoledger:cache:${email}:`)) doomed.push(k) + } + doomed.forEach(k => localStorage.removeItem(k)) + } catch { /* ignore */ } } -/** Resend the OTP for an in-flight signup (respects server-side cooldown). */ -export async function signupResend(email) { - try { - const res = await fetch(`${API_BASE}/auth/signup/resend`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email }), - }) - const data = await res.json().catch(() => ({})) - if (res.ok) return { ok: true, data } - return { ok: false, error: data.error || 'Could not resend code' } - } catch (e) { - return { ok: false, error: 'Cannot connect to server. Please try again.' } +export function setCacheEntry(endpoint, data, ttlSeconds = 300) { + const email = getUserEmail() + if (!email) return + const key = getCacheKey(email, endpoint) + const envelope = { + data, + expires: Date.now() + ttlSeconds * 1000, } -} - -/** - * Step 2 — verify the OTP. On success, the backend creates the account and - * returns a JWT so we can drop the user straight into the app. - */ -export async function signupVerify(email, otp) { try { - const res = await fetch(`${API_BASE}/auth/signup/verify`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, otp }), - credentials: 'include', // accept the auth cookie on success - }) - const data = await res.json().catch(() => ({})) - if (res.ok && data.email) { - setUserEmail(data.email || email) - if (data.name) setUserName(data.name) - if (data.username) setUsername(data.username) - return { ok: true, data } - } - return { ok: false, error: data.error || 'Verification failed' } - } catch (e) { - return { ok: false, error: 'Cannot connect to server. Please try again.' } - } + localStorage.setItem(key, JSON.stringify(envelope)) + } catch { /* ignore storage quota errors */ } } -/** Login: exchange email + password for an HttpOnly auth cookie. */ -export async function login(email, password) { +export function getCacheEntry(endpoint) { + const email = getUserEmail() + if (!email) return null + const key = getCacheKey(email, endpoint) try { - const res = await fetch(`${API_BASE}/auth/generateToken`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username: email, password }), - credentials: 'include', // accept the auth cookie - }) - if (!res.ok) { - const errBody = await res.text().catch(() => '') - return { success: false, error: errBody || 'Invalid email or password' } + const text = localStorage.getItem(key) + if (!text) return null + const envelope = JSON.parse(text) + if (Date.now() > envelope.expires) { + localStorage.removeItem(key) + return null } - // Server set the auth cookie; we just persist UI hints. - setUserEmail(email) - // Fetch the user's name + profile pic — the cookie auto-authenticates. - try { - const meRes = await fetch(`${API_BASE}/auth/me`, { - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - }) - if (meRes.ok) { - const me = await meRes.json() - if (me.name) setUserName(me.name) - setUsername(me.username || '') - try { - const mod = await import('../utils/profilePic') - mod.setProfilePic(me.profilePic || null) - } catch { /* ignore */ } - } - } catch { /* best-effort, login still succeeded */ } - return { success: true, email } - } catch (err) { - return { success: false, error: 'Cannot connect to server. Please ensure the backend is running.' } - } + return envelope.data + } catch { return null } } -/** Logout user */ -export function logout() { clearAuth() } +/* ── Dashboard Stats ── */ -/** Fetch current user's profile from backend */ -export async function fetchMe() { - return authFetchJson('/auth/me') -} +export async function getDashboardStats() { + const cached = getCacheEntry('dashboard') + if (cached) return { ok: true, data: cached } -/** Update the authenticated user's display name */ -export async function updateProfile(name) { - return authFetchJson('/auth/me', { - method: 'PUT', - body: JSON.stringify({ name }), - }) -} - -/** Change the authenticated user's password */ -export async function changePassword(currentPassword, newPassword) { - return authFetchJson('/auth/password', { - method: 'PUT', - body: JSON.stringify({ currentPassword, newPassword }), - }) + const r = await authFetchJson('/api/platforms/dashboard') + if (r.ok) { + setCacheEntry('dashboard', r.data, 600) + return r + } + return r } -/** Permanently delete the authenticated user's account */ -export async function deleteAccount() { - return authFetchJson('/auth/me', { method: 'DELETE' }) -} +/* ── Missions / Practice ── */ -/** Upload a new profile picture (base64 data URL). */ -export async function updateProfilePicture(dataUrl) { - return authFetchJson('/auth/me/picture', { - method: 'PUT', - body: JSON.stringify({ profilePic: dataUrl }), - }) -} +export async function getMissions() { + const cached = getCacheEntry('missions') + if (cached) return { ok: true, data: cached } -/** Remove the authenticated user's profile picture. */ -export async function removeProfilePicture() { - return authFetchJson('/auth/me/picture', { method: 'DELETE' }) + const r = await authFetchJson('/api/missions') + if (r.ok) { + setCacheEntry('missions', r.data, 600) + return r + } + return r } -/* ── Notification preferences ── */ +/* ── Community ── */ -/** Fetch the user's reminder email preferences. */ -export async function fetchNotificationPrefs() { - return authFetchJson('/auth/me/notifications') +export async function getCommunityPosts(limit = 10, offset = 0) { + const r = await authFetchJson(`/api/community/posts?limit=${limit}&offset=${offset}`) + return r } -/** Save { enabled, reminderTime 'HH:mm', reminderTimezone 'Area/City' }. */ -export async function updateNotificationPrefs(prefs) { - return authFetchJson('/auth/me/notifications', { - method: 'PUT', - body: JSON.stringify(prefs), - }) +export async function getCommunityPost(postId) { + const r = await authFetchJson(`/api/community/posts/${postId}`) + return r } -/* ── Platform linking (stored in DB via backend) ── */ - -/** - * Link a platform account. Triggers an immediate sync from the platform API. - * platform: 'leetcode' | 'codeforces' - */ -export async function linkPlatform(platform, username) { - const res = await authFetch('/api/platforms/link', { +export async function createCommunityPost(content, topic) { + const r = await authFetchJson('/api/community/posts', { method: 'POST', - body: JSON.stringify({ platform, username }), + body: JSON.stringify({ content, topic }), }) - const data = await res.json() - if (res.ok) { - // Mirror in localStorage for instant UI feedback - const platforms = getLinkedPlatforms() - platforms[platform] = username - savePlatforms(platforms) - return { success: true, data } - } - // 409 = this coding account is already owned by another user - if (res.status === 409) { - return { success: false, conflict: true, error: data.error || 'This account is already linked to another user.' } - } - return { success: false, error: data.error || 'Failed to link platform' } + return r } -/** Verify a LeetCode username exists (public endpoint) */ -export async function verifyLeetCodeUsername(username) { - try { - const res = await fetch(`${API_BASE}/api/leetcode/submissions/${encodeURIComponent(username)}`) - if (res.ok) { - const data = await res.json() - return { valid: true, data } - } - return { valid: false, error: 'Username not found' } - } catch (e) { - return { valid: false, error: e.message } - } +export async function likeCommunityPost(postId) { + const r = await authFetchJson(`/api/community/posts/${postId}/like`, { + method: 'POST', + }) + return r } -/** Verify a Codeforces handle exists (public endpoint) */ -export async function verifyCodeforcesHandle(handle) { - try { - const res = await fetch(`${API_BASE}/api/codeforces/user/${encodeURIComponent(handle)}`) - if (res.ok) { - const data = await res.json() - return { valid: data.exists, data } - } - return { valid: false, error: 'Handle not found' } - } catch (e) { - return { valid: false, error: e.message } - } +export async function unlikeCommunityPost(postId) { + const r = await authFetchJson(`/api/community/posts/${postId}/unlike`, { + method: 'POST', + }) + return r } -/* ── Dashboard data (served from DB, synced from real platform APIs) ── */ - -/* - * ─── Persistent client-side cache ───────────────────────────────────────── - * - * Dashboard data (stats, calendar heatmap, recent submissions) is expensive - * on the backend — a cold-started free-tier instance plus live-sync against - * LeetCode / Codeforces APIs can take several seconds per call. Re-paying - * that cost on every page load (or every tab-switch) is what was making the - * post-onboarding "welcome to your dashboard" moment feel slow. - * - * The contract now: - * - First read after login → fetch from server, store in localStorage. - * - Every subsequent read → served instantly from localStorage. - * - User clicks "Sync" → invalidate cache + force a fresh fetch. - * - User logs out → cache wiped (see clearAuth). - * - * In-memory `inflight` Map still dedupes concurrent callers in the same - * tick so Sidebar + TopBar + page don't each hit the same endpoint thrice. - * - * Cache keys are scoped per-user (via the JWT email) so switching accounts - * can't leak another user's numbers onto the page. - */ -const _inflight = new Map() // key -> Promise (dedup concurrent callers) +/* ── Contests ── */ -function _cacheKey(key) { - const email = getUserEmail() || 'anon' - return `algoledger:cache:${email}:${key}` -} -function _readPersisted(key) { - try { - const raw = localStorage.getItem(_cacheKey(key)) - if (!raw) return null - return JSON.parse(raw) // { fetchedAt, value } - } catch { return null } -} -function _writePersisted(key, value) { - try { - localStorage.setItem( - _cacheKey(key), - JSON.stringify({ fetchedAt: Date.now(), value }) - ) - } catch { /* quota exceeded — degrade gracefully */ } -} -function _removePersisted(key) { - try { localStorage.removeItem(_cacheKey(key)) } catch { /* ignore */ } -} +export async function getContests() { + const cached = getCacheEntry('contests') + if (cached) return { ok: true, data: cached } -/** - * Persistent cached fetch. - * - forceRefresh=true → always hit the network, refresh the cache. - * - otherwise → return cached value if present; else fetch once. - */ -function cachedFetch(key, fetcher, { forceRefresh = false } = {}) { - if (!forceRefresh) { - const persisted = _readPersisted(key) - if (persisted) return Promise.resolve(persisted.value) - const pending = _inflight.get(key) - if (pending) return pending + const r = await authFetchJson('/api/contests') + if (r.ok) { + setCacheEntry('contests', r.data, 3600) + return r } - const p = fetcher() - .then(value => { - const isErrorEnvelope = value && typeof value === 'object' && value.success === false - - // Self-heal on 401: cookie expired / missing / revoked. The user - // can't recover by clicking Sync (they wouldn't even know to try) - // — we wipe local state and bounce to /login automatically so they - // can sign in again without doing anything in DevTools. - if (isErrorEnvelope && typeof value.error === 'string' && /^HTTP 401\b/.test(value.error)) { - _inflight.delete(key) - try { clearAuth() } catch { /* ignore */ } - if (typeof window !== 'undefined' && window.location.pathname !== '/login') { - try { window.location.href = '/login' } catch { /* ignore */ } - } - return value - } - - // Only cache SUCCESSFUL responses. Caching `{ success: false, ... }` - // would freeze the UI in an error state until the user manually - // synced — which is exactly the bug we keep hitting after auth - // changes. So skip the cache write on any error envelope. - if (!isErrorEnvelope) _writePersisted(key, value) - _inflight.delete(key) - return value - }) - .catch(err => { - // Don't poison the cache with thrown errors either. - _inflight.delete(key) - throw err - }) - _inflight.set(key, p) - return p -} - -/** Timestamp (ms since epoch) of the cached entry, or null if not cached. */ -export function getCacheTimestamp(key) { - const entry = _readPersisted(key) - return entry?.fetchedAt ?? null -} - -/** Most recent sync time across dashboard + calendar + submissions, or null. */ -export function getLastSyncedAt() { - const stamps = [ - getCacheTimestamp('dashboard'), - getCacheTimestamp('calendar'), - getCacheTimestamp('submissions'), - ].filter(Boolean) - return stamps.length ? Math.min(...stamps) : null -} - -/** Invalidate cached dashboard data — call on sync/logout so the next read is fresh. */ -export function invalidateDashboardCache() { - _removePersisted('dashboard') - _removePersisted('calendar') - _removePersisted('submissions') - _inflight.delete('dashboard') - _inflight.delete('calendar') - _inflight.delete('submissions') -} - -/** - * Nuke every cache key for the current user. Called from clearAuth() so - * logging out doesn't leave stale numbers in storage. - */ -export function clearAllCache() { - try { - const email = getUserEmail() || 'anon' - const prefix = `algoledger:cache:${email}:` - const doomed = [] - for (let i = 0; i < localStorage.length; i++) { - const k = localStorage.key(i) - if (k && k.startsWith(prefix)) doomed.push(k) - } - doomed.forEach(k => localStorage.removeItem(k)) - } catch { /* ignore */ } - _inflight.clear() + return r } -/** - * Fetch dashboard stats for the logged-in user. - * Returns: { totalSolved, easySolved, mediumSolved, hardSolved, - * currentStreak, longestStreak, platforms[], topics[], linkedPlatforms[] } - */ -export function fetchDashboardData(opts) { - return cachedFetch('dashboard', async () => { - const res = await authFetch('/api/platforms/dashboard') - if (res.ok) return { success: true, data: await res.json() } - return { success: false, error: `HTTP ${res.status}` } - }, opts) -} +/* ── Problems ── */ -export function fetchCalendarData(opts) { - return cachedFetch('calendar', async () => { - const res = await authFetch('/api/platforms/calendar') - if (res.ok) return { success: true, data: await res.json() } - return { success: false, error: `HTTP ${res.status}` } - }, opts) +export async function getProblems(filters = {}) { + const params = new URLSearchParams(filters) + const r = await authFetchJson(`/api/problems?${params}`) + return r } -/** - * Force-sync all linked platforms from their live APIs → update DB → return fresh stats. - * Invalidates the client cache so the next read fetches the freshly-synced data. - */ -export async function syncAllPlatforms() { - const res = await authFetch('/api/platforms/sync', { method: 'POST' }) - invalidateDashboardCache() - if (res.ok) { - return { success: true, data: await res.json() } - } - return { success: false, error: `HTTP ${res.status}` } -} - -/* ── Legacy helpers (kept for backward compat with OnboardingPage) ── */ - -export async function addLeetCode(username) { - return linkPlatform('leetcode', username) -} +/* ── Challenges ── */ -export async function addCodeforces(handle) { - return linkPlatform('codeforces', handle) -} +export async function getChallenges() { + const cached = getCacheEntry('challenges') + if (cached) return { ok: true, data: cached } -/** @deprecated Use fetchDashboardData() instead */ -export async function fetchAllPlatformData() { - return fetchDashboardData() + const r = await authFetchJson('/api/challenges') + if (r.ok) { + setCacheEntry('challenges', r.data, 600) + return r + } + return r } - -export function fetchLeetCodeSubmissions(username, opts) { - return cachedFetch('submissions', async () => { - try { - const res = await fetch(`${API_BASE}/api/leetcode/submissions/${encodeURIComponent(username)}`) - if (res.ok) { - const data = await res.json() - return { success: true, data: data.submissions || [] } - } - return { success: false, error: 'Failed to fetch' } - } catch (e) { - return { success: false, error: e.message } - } - }, opts) +export async function getUserChallenges() { + const r = await authFetchJson('/api/challenges/mine') + return r } -/* ───────────────────────────────────────────── - CHALLENGE / CONTEST APIs -───────────────────────────────────────────── */ +/* ── Profile ── */ -/** - * Create a challenge. First arg accepts either: - * - a string (legacy: opponent's email address), or - * - an object (preferred): { opponentUsername } or { opponentEmail } - */ -export async function createChallenge(opponent, contestType, customCounts = {}) { - try { - const ref = typeof opponent === 'string' - ? { opponentEmail: opponent } - : (opponent || {}) - const body = { ...ref, contestType, ...customCounts } - const res = await authFetch('/challenges', { - method: 'POST', - body: JSON.stringify(body), - }) - const data = await res.json() - if (res.ok) return { success: true, data } - return { success: false, error: data.error || 'Failed to create challenge' } - } catch (e) { return { success: false, error: e.message } } -} - -export async function getChallenge(id) { - try { - const res = await authFetch(`/challenges/${id}`) - if (res.ok) return { success: true, data: await res.json() } - return { success: false, error: 'Not found' } - } catch (e) { return { success: false, error: e.message } } -} +export async function getUserProfile() { + const cached = getCacheEntry('profile') + if (cached) return { ok: true, data: cached } -export async function acceptChallenge(id) { - try { - const res = await authFetch(`/challenges/${id}/accept`, { method: 'POST' }) - const data = await res.json() - if (res.ok) return { success: true, data } - return { success: false, error: data.error || 'Failed' } - } catch (e) { return { success: false, error: e.message } } -} - -export async function declineChallenge(id) { - try { - const res = await authFetch(`/challenges/${id}/decline`, { method: 'POST' }) - const data = await res.json() - if (res.ok) return { success: true, data } - return { success: false, error: data.error || 'Failed' } - } catch (e) { return { success: false, error: e.message } } + const r = await authFetchJson('/api/user/profile') + if (r.ok) { + setCacheEntry('profile', r.data, 600) + return r + } + return r } -export async function fetchMyChallenges() { - try { - const res = await authFetch('/challenges/mine') - if (res.ok) return { success: true, data: await res.json() } - return { success: false, error: 'Failed' } - } catch (e) { return { success: false, error: e.message } } +export async function updateUserProfile(updates) { + const r = await authFetchJson('/api/user/profile', { + method: 'PUT', + body: JSON.stringify(updates), + }) + if (r.ok) { + clearAllCache() // Invalidate profile cache + } + return r } -export async function fetchInvitations() { - try { - const res = await authFetch('/challenges/invitations') - if (res.ok) return { success: true, data: await res.json() } - return { success: false, error: 'Failed' } - } catch (e) { return { success: false, error: e.message } } -} +/* ── Recommendations ── */ -export async function fetchLeaderboard(id) { - try { - const res = await authFetch(`/challenges/${id}/leaderboard`) - if (res.ok) return { success: true, data: await res.json() } - return { success: false, error: 'Failed' } - } catch (e) { return { success: false, error: e.message } } -} +export async function getRecommendations() { + const cached = getCacheEntry('recommendations') + if (cached) return { ok: true, data: cached } -export async function finishChallenge(id) { - try { - const res = await authFetch(`/challenges/${id}/finish`, { method: 'POST' }) - const data = await res.json() - if (res.ok) return { success: true, data } - return { success: false, error: data.error || 'Failed' } - } catch (e) { return { success: false, error: e.message } } + const r = await authFetchJson('/api/recommendations') + if (r.ok) { + setCacheEntry('recommendations', r.data, 1200) + return r + } + return r } -/* ───────────────────────────────────────────── - COMMUNITY / POSTS APIs -───────────────────────────────────────────── */ +/* ── Notifications ── */ -export async function fetchFeed(page = 0, size = 10) { - return authFetchJson(`/api/posts?page=${page}&size=${size}`) +export async function getUnreadNotificationCount() { + const r = await authFetchJson('/api/notifications/unread-count') + return r } -export async function fetchFeedByTopic(topic, page = 0, size = 10) { - return authFetchJson(`/api/posts/topic/${encodeURIComponent(topic)}?page=${page}&size=${size}`) +export async function getNotifications() { + const r = await authFetchJson('/api/notifications') + return r } -export async function fetchPost(id) { - return authFetchJson(`/api/posts/${id}`) -} +/* ── Authentication ── */ -export async function createPost(title, topic, content) { - return authFetchJson('/api/posts', { +export async function loginWithEmailPassword(email, password) { + const r = await authFetchJson('/auth/login', { method: 'POST', - body: JSON.stringify({ title, topic, content }), + body: JSON.stringify({ email, password }), }) + if (r.ok) { + setUserEmail(r.data.email) + setUserName(r.data.name) + setUsername(r.data.username) + } + return r } -export async function toggleLike(id) { - return authFetchJson(`/api/posts/${id}/like`, { method: 'POST' }) -} - -export async function deletePost(id) { - return authFetchJson(`/api/posts/${id}`, { method: 'DELETE' }) -} - -export async function fetchMyPosts() { - return authFetchJson('/api/posts/mine') -} - -/* ── Save posts ── */ -export async function savePost(id) { return authFetchJson(`/api/posts/${id}/save`, { method: 'POST' }) } -export async function unsavePost(id) { return authFetchJson(`/api/posts/${id}/save`, { method: 'DELETE' }) } -export async function fetchSavedPosts() { return authFetchJson('/api/posts/saved') } - -/* ── Follow graph ── */ -export async function followUser(username) { return authFetchJson(`/api/follow/${encodeURIComponent(username)}`, { method: 'POST' }) } -export async function unfollowUser(username) { return authFetchJson(`/api/follow/${encodeURIComponent(username)}`, { method: 'DELETE' }) } -export async function fetchFollowStatus(username) { return authFetchJson(`/api/follow/status/${encodeURIComponent(username)}`) } -export async function fetchFollowers(username) { return authFetchJson(`/api/follow/${encodeURIComponent(username)}/followers`) } -export async function fetchFollowing(username) { return authFetchJson(`/api/follow/${encodeURIComponent(username)}/following`) } - -/* ── In-app notifications ── - * - * The bell in the TopBar polls fetchUnreadCount every minute (cheap, just - * a number) and fires fetchNotifications when the user actually opens the - * dropdown. We deliberately DON'T cache these — notifications need to feel - * live, and the payloads are small. */ -export async function fetchNotifications(page = 0, size = 20) { - return authFetchJson(`/api/notifications?page=${page}&size=${size}`) -} -export async function fetchUnreadNotifCount() { - return authFetchJson('/api/notifications/unread-count') -} -export async function markAllNotificationsRead() { - return authFetchJson('/api/notifications/mark-all-read', { method: 'POST' }) -} -/** Admin only — broadcast a SYSTEM notification to every registered user. - * Returns 403 unless the caller's email is in `app.admin.emails` server-side. */ -export async function broadcastNotification({ title, message, link }) { - return authFetchJson('/api/notifications/broadcast', { +export async function signupWithEmail(name, email, password, username) { + const r = await authFetchJson('/auth/signup/request', { method: 'POST', - body: JSON.stringify({ title, message, link }), + body: JSON.stringify({ name, email, password, username }), }) + return r } -// ── Recommendations ──────────────────────────────────────────────────────────── -export async function completeDailyMission() { - return authFetchJson('/recommendations/daily-mission/complete', { method: 'POST' }) -} +export async function getCurrentUser() { + const r = await authFetchJson('/auth/me') + if (r.ok) { + setUserEmail(r.data.email) + setUserName(r.data.name) + setUsername(r.data.username) + } + return r +} \ No newline at end of file diff --git a/frontend/website/src/services/mockApi.js b/frontend/website/src/services/mockApi.js new file mode 100644 index 0000000..899a463 --- /dev/null +++ b/frontend/website/src/services/mockApi.js @@ -0,0 +1,286 @@ +/** + * Mock API Wrapper + * Enable/disable with: localStorage.setItem('useMockApi', 'true'/'false') + * Check status with: localStorage.getItem('useMockApi') + */ +// localStorage.setItem('useMockApi', 'true'); +// localStorage.setItem('jwt_email', 'testuser@example.com'); +// localStorage.setItem('jwt_name', 'Test User'); +// localStorage.setItem('jwt_username', 'testuser'); +// window.location.href = '/dashboard'; +import { + MOCK_CHALLENGES_DATA, + MOCK_COMMUNITY_DATA, + MOCK_CONTESTS_DATA, + MOCK_DASHBOARD_DATA, + MOCK_PRACTICE_DATA, + MOCK_PROBLEMS_DATA, + MOCK_PROFILE_DATA, + MOCK_RECOMMENDATIONS_DATA, + MOCK_USER, +} from './mockData' + +export function isMockApiEnabled() { + return localStorage.getItem('useMockApi') === 'true' +} + +export function enableMockApi() { + localStorage.setItem('useMockApi', 'true') + console.log('✅ Mock API enabled. Page will reload.') + window.location.reload() +} + +export function disableMockApi() { + localStorage.removeItem('useMockApi') + console.log('❌ Mock API disabled. Page will reload.') + window.location.reload() +} + +export function getMockApiStatus() { + return { + enabled: isMockApiEnabled(), + message: isMockApiEnabled() + ? '✅ Using Mock API (no backend needed)' + : '❌ Using Real API (backend required)', + } +} + +/** + * Intercept authFetchJson calls and return mock data + * Usage: wrap the real authFetchJson with this function + */ +export async function mockAuthFetchJson(path, options = {}) { + if (!isMockApiEnabled()) { + // Fall through to real API + return null + } + + // Add small delay to simulate network latency + await new Promise(resolve => setTimeout(resolve, 300 + Math.random() * 200)) + + console.log(`🎭 [MOCK API] ${options.method || 'GET'} ${path}`) + + // LOGIN - Check credentials + if (path === '/auth/login') { + const { email, password } = JSON.parse(options.body || '{}') + if (email && password) { + return { + ok: true, + data: { + email, + name: MOCK_USER.name, + username: MOCK_USER.username, + }, + } + } + return { ok: false, error: 'Invalid email or password' } + } + + // SIGNUP - Accept any new user + if (path === '/auth/signup/request') { + const { name, email, password, username } = JSON.parse(options.body || '{}') + if (email && password) { + return { + ok: true, + data: { + email, + name, + username, + requiresOTP: false, + }, + } + } + return { ok: false, error: 'Invalid signup data' } + } + + // DASHBOARD DATA + if (path === '/api/platforms/dashboard') { + return { + ok: true, + data: MOCK_DASHBOARD_DATA, + } + } + + // PRACTICE MISSIONS + if (path === '/api/missions') { + return { + ok: true, + data: MOCK_PRACTICE_DATA, + } + } + + // COMMUNITY POSTS + if (path === '/api/community/posts') { + return { + ok: true, + data: MOCK_COMMUNITY_DATA, + } + } + + if (path.startsWith('/api/community/posts/')) { + const postId = path.split('/').pop() + return { + ok: true, + data: MOCK_COMMUNITY_DATA.posts.find(p => p.id === parseInt(postId)), + } + } + + // CONTESTS + if (path === '/api/contests') { + return { + ok: true, + data: MOCK_CONTESTS_DATA, + } + } + + // PROFILE + if (path === '/api/user/profile') { + return { + ok: true, + data: MOCK_PROFILE_DATA, + } + } + + // PROFILE UPDATE + if (path === '/api/user/profile' && options.method === 'PUT') { + return { + ok: true, + data: { ...MOCK_PROFILE_DATA.user, ...JSON.parse(options.body || '{}') }, + } + } + + // CHALLENGES + if (path === '/api/challenges') { + return { + ok: true, + data: MOCK_CHALLENGES_DATA, + } + } + + // PROBLEMS + if (path === '/api/problems') { + return { + ok: true, + data: MOCK_PROBLEMS_DATA, + } + } + + // RECOMMENDATIONS + if (path === '/api/recommendations') { + return { + ok: true, + data: MOCK_RECOMMENDATIONS_DATA, + } + } + + // VERIFICATION (always succeed for testing) + if (path === '/api/verify/start') { + const { platform, handle } = JSON.parse(options.body || '{}') + return { + ok: true, + data: { + problemSlug: 'two-sum', + problemName: 'Two Sum', + problemUrl: `https://${platform}.com/problems/two-sum/`, + startTime: new Date().toISOString(), + }, + } + } + + if (path === '/api/verify/check') { + return { + ok: true, + data: { verified: true }, + } + } + + // GET CURRENT USER + if (path === '/auth/me') { + return { + ok: true, + data: MOCK_USER, + } + } + + // NOTIFICATIONS + if (path === '/api/notifications/unread-count') { + return { + ok: true, + data: { count: 3 }, + } + } + + if (path === '/api/notifications') { + return { + ok: true, + data: { + notifications: [ + { id: 1, message: 'You solved Two Sum!', read: false, timestamp: new Date() }, + { id: 2, message: 'New contest available!', read: false, timestamp: new Date() }, + { id: 3, message: 'Keep your streak going!', read: true, timestamp: new Date() }, + ], + }, + } + } + + // CHALLENGES + if (path === '/challenges/mine' || path === '/api/challenges/mine') { + return { + ok: true, + data: MOCK_CHALLENGES_DATA.challenges || [], + } + } + + if (path === '/challenges/invitations' || path === '/api/challenges/invitations') { + return { + ok: true, + data: [], + } + } + + // COMMUNITY POSTS + if (path.includes('/api/posts') || path.includes('/posts')) { + return { + ok: true, + data: { + posts: MOCK_COMMUNITY_DATA.posts || [], + hasNext: false, + }, + } + } + + // CATCH-ALL: For any missing endpoint, provide reasonable defaults + console.log(`🎭 [MOCK API] Returning default data for ${path}`) + + // Return empty data based on endpoint pattern + if (path.includes('/dashboard')) { + return { ok: true, data: MOCK_DASHBOARD_DATA } + } + if (path.includes('/profile')) { + return { ok: true, data: MOCK_PROFILE_DATA } + } + if (path.includes('/community')) { + return { ok: true, data: MOCK_COMMUNITY_DATA } + } + if (path.includes('/problems')) { + return { ok: true, data: MOCK_PROBLEMS_DATA } + } + if (path.includes('/challenges')) { + return { ok: true, data: MOCK_CHALLENGES_DATA.challenges || [] } + } + if (path.includes('/recommendations')) { + return { ok: true, data: MOCK_RECOMMENDATIONS_DATA } + } + if (path.includes('/contests')) { + return { ok: true, data: MOCK_CONTESTS_DATA } + } + if (path.includes('/missions')) { + return { ok: true, data: MOCK_PRACTICE_DATA } + } + + // FALLBACK - Return empty success for unknown endpoints + return { + ok: true, + data: [], + } +} diff --git a/frontend/website/src/services/mockData.js b/frontend/website/src/services/mockData.js new file mode 100644 index 0000000..c008534 --- /dev/null +++ b/frontend/website/src/services/mockData.js @@ -0,0 +1,330 @@ +/** + * Mock API Data for Frontend Testing + * Replace this with real API calls when backend is ready + */ + +export const MOCK_USER = { + email: 'testuser@example.com', + name: 'Test User', + username: 'testuser', + streak: 15, + totalProblems: 245, + totalContests: 12, +} + +export const MOCK_DASHBOARD_DATA = { + streak: 15, + totalProblems: 245, + totalContests: 12, + totalTime: 1230, + easyCount: 89, + mediumCount: 120, + hardCount: 36, + platformData: { + leetcode: { count: 150, streak: 12 }, + codeforces: { count: 95, streak: 8 }, + geeksforgeeks: { count: 0, streak: 0 }, + }, + recentProblems: [ + { + id: 1, + title: 'Two Sum', + platform: 'leetcode', + difficulty: 'Easy', + status: 'solved', + timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000), + }, + { + id: 2, + title: 'Median of Two Sorted Arrays', + platform: 'leetcode', + difficulty: 'Hard', + status: 'solved', + timestamp: new Date(Date.now() - 5 * 60 * 60 * 1000), + }, + { + id: 3, + title: 'A. Watermelon', + platform: 'codeforces', + difficulty: 'Easy', + status: 'attempted', + timestamp: new Date(Date.now() - 24 * 60 * 60 * 1000), + }, + ], + achievements: [ + { id: 1, name: 'First Steps', description: 'Solve your first problem', unlockedAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) }, + { id: 2, name: '7-Day Streak', description: 'Maintain a 7-day streak', unlockedAt: new Date(Date.now() - 20 * 24 * 60 * 60 * 1000) }, + { id: 3, name: 'Century Club', description: 'Solve 100 problems', unlockedAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000) }, + ], +} + +export const MOCK_PRACTICE_DATA = { + missions: [ + { + id: 1, + category: 'mission', + difficulty: 'Easy', + platform: 'leetcode', + title: 'Two Sum', + description: 'Find two numbers that add up to target', + url: 'https://leetcode.com/problems/two-sum/', + estimatedTime: 15, + }, + { + id: 2, + category: 'weakness', + difficulty: 'Medium', + platform: 'leetcode', + title: 'LRU Cache', + description: 'Design and implement LRU Cache', + url: 'https://leetcode.com/problems/lru-cache/', + estimatedTime: 45, + }, + { + id: 3, + category: 'levelup', + difficulty: 'Hard', + platform: 'leetcode', + title: 'Merge K Sorted Lists', + description: 'Merge multiple sorted linked lists', + url: 'https://leetcode.com/problems/merge-k-sorted-lists/', + estimatedTime: 60, + }, + { + id: 4, + category: 'explore', + difficulty: 'Medium', + platform: 'codeforces', + title: 'B. Restaurant', + description: 'Solve Codeforces problem', + url: 'https://codeforces.com/problemset/problem/1/B', + estimatedTime: 30, + }, + { + id: 5, + category: 'stretch', + difficulty: 'Hard', + platform: 'leetcode', + title: 'Edit Distance', + description: 'Dynamic Programming challenge', + url: 'https://leetcode.com/problems/edit-distance/', + estimatedTime: 50, + }, + ], +} + +export const MOCK_COMMUNITY_DATA = { + posts: [ + { + id: 1, + author: 'alice', + content: 'Just completed my 100th LeetCode problem! 🎉', + topic: 'general', + likes: 45, + comments: 12, + timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000), + liked: false, + }, + { + id: 2, + author: 'bob', + content: 'Best way to master dynamic programming - Tips and tricks', + topic: 'dynamic-programming', + likes: 89, + comments: 23, + timestamp: new Date(Date.now() - 5 * 60 * 60 * 1000), + liked: true, + }, + { + id: 3, + author: 'charlie', + content: 'Struggling with graph problems? Start with BFS/DFS', + topic: 'graphs', + likes: 67, + comments: 18, + timestamp: new Date(Date.now() - 12 * 60 * 60 * 1000), + liked: false, + }, + { + id: 4, + author: 'diana', + content: 'System Design interview prep - resources and timeline', + topic: 'system-design', + likes: 123, + comments: 34, + timestamp: new Date(Date.now() - 24 * 60 * 60 * 1000), + liked: false, + }, + ], +} + +export const MOCK_CONTESTS_DATA = { + upcoming: [ + { + id: 1, + platform: 'leetcode', + name: 'LeetCode Weekly Contest 385', + startTime: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + duration: 90, + participantCount: 5432, + difficulty: 'Medium', + registered: true, + }, + { + id: 2, + platform: 'codeforces', + name: 'Codeforces Round #923', + startTime: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000), + duration: 120, + participantCount: 8234, + difficulty: 'Mixed', + registered: false, + }, + ], + recent: [ + { + id: 3, + platform: 'leetcode', + name: 'LeetCode Weekly Contest 384', + startTime: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), + duration: 90, + rank: 245, + totalParticipants: 5123, + score: 18, + }, + ], +} + +export const MOCK_PROFILE_DATA = { + user: MOCK_USER, + stats: { + totalProblems: 245, + totalContests: 12, + currentStreak: 15, + longestStreak: 32, + easyCount: 89, + mediumCount: 120, + hardCount: 36, + acceptance: 0.87, + }, + platforms: [ + { + platform: 'leetcode', + username: 'testuser', + solved: 150, + verified: true, + verifiedAt: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000), + }, + { + platform: 'codeforces', + username: 'testuser', + solved: 95, + verified: true, + verifiedAt: new Date(Date.now() - 45 * 24 * 60 * 60 * 1000), + }, + ], + badges: [ + { name: 'Century', description: '100 problems solved', icon: '🏆' }, + { name: 'Legend', description: '15-day streak', icon: '⭐' }, + { name: 'Speedster', description: 'Complete 5 problems in 1 hour', icon: '⚡' }, + ], +} + +export const MOCK_CHALLENGES_DATA = { + challenges: [ + { + id: 1, + title: 'Week 1: Arrays & Hashing', + description: 'Master the fundamentals', + difficulty: 'Easy', + duration: '7 days', + problems: 10, + completed: 6, + progress: 60, + }, + { + id: 2, + title: 'Week 2: Two Pointers', + description: 'Solve problems efficiently', + difficulty: 'Medium', + duration: '7 days', + problems: 12, + completed: 4, + progress: 33, + }, + { + id: 3, + title: 'Binary Trees Bootcamp', + description: 'Deep dive into tree problems', + difficulty: 'Medium', + duration: '14 days', + problems: 20, + completed: 0, + progress: 0, + }, + ], +} + +export const MOCK_PROBLEMS_DATA = { + problems: [ + { + id: 1, + title: 'Two Sum', + difficulty: 'Easy', + platform: 'leetcode', + acceptance: 47.3, + likes: 15243, + dislikes: 456, + }, + { + id: 2, + title: 'Add Two Numbers', + difficulty: 'Medium', + platform: 'leetcode', + acceptance: 32.1, + likes: 8234, + dislikes: 1234, + }, + { + id: 3, + title: 'Median of Two Sorted Arrays', + difficulty: 'Hard', + platform: 'leetcode', + acceptance: 28.5, + likes: 9234, + dislikes: 2345, + }, + { + id: 4, + title: 'A. Watermelon', + difficulty: 'Easy', + platform: 'codeforces', + acceptance: 98.2, + likes: 234, + dislikes: 12, + }, + ], +} + +export const MOCK_RECOMMENDATIONS_DATA = { + recommendations: [ + { + id: 1, + title: 'Strengthen Your Weak Areas', + problems: [ + { title: 'LRU Cache', difficulty: 'Hard', topic: 'Design' }, + { title: 'Word Ladder', difficulty: 'Hard', topic: 'BFS' }, + ], + reason: 'Based on your recent attempts', + }, + { + id: 2, + title: 'Trending Problems This Week', + problems: [ + { title: 'Maximum Sum Subarray', difficulty: 'Medium', topic: 'DP' }, + { title: 'Merge K Sorted Lists', difficulty: 'Hard', topic: 'Heap' }, + ], + reason: 'Popular among users with your skill level', + }, + ], +} diff --git a/frontend/website/src/tour/TourContext.jsx b/frontend/website/src/tour/TourContext.jsx new file mode 100644 index 0000000..743dd39 --- /dev/null +++ b/frontend/website/src/tour/TourContext.jsx @@ -0,0 +1,87 @@ +import { createContext, useContext, useState } from 'react' + +const TourContext = createContext() + +export const TourProvider = ({ children }) => { + const [isTourActive, setIsTourActive] = useState(false) + const [currentStep, setCurrentStep] = useState(0) + const [tourCompleted, setTourCompleted] = useState(() => { + // Check if user has already completed the tour + const stored = localStorage.getItem('algoSprint_tourCompleted') + return stored === 'true' + }) + + // Start tour - typically called after onboarding completes + const startTour = () => { + localStorage.setItem('algoSprint_tourStarted', 'true') + setIsTourActive(true) + setCurrentStep(0) + } + + // Go to next step + const nextStep = () => { + setCurrentStep(prev => prev + 1) + } + + // Go to previous step + const prevStep = () => { + setCurrentStep(prev => Math.max(0, prev - 1)) + } + + // Jump to specific step + const goToStep = (stepIndex) => { + setCurrentStep(stepIndex) + } + + // Complete tour + const completeTour = () => { + setIsTourActive(false) + setTourCompleted(true) + localStorage.setItem('algoSprint_tourCompleted', 'true') + } + + // Skip tour + const skipTour = () => { + setIsTourActive(false) + setTourCompleted(true) + localStorage.setItem('algoSprint_tourCompleted', 'true') + } + + // Reset tour (for demo purposes) + const resetTour = () => { + setTourCompleted(false) + setCurrentStep(0) + setIsTourActive(false) + localStorage.removeItem('algoSprint_tourCompleted') + localStorage.removeItem('algoSprint_tourStarted') + } + + return ( + + {children} + + ) +} + +export const useTour = () => { + const context = useContext(TourContext) + if (!context) { + throw new Error('useTour must be used within TourProvider') + } + return context +} \ No newline at end of file diff --git a/frontend/website/src/tour/TourOverlay.jsx b/frontend/website/src/tour/TourOverlay.jsx new file mode 100644 index 0000000..4ed22a8 --- /dev/null +++ b/frontend/website/src/tour/TourOverlay.jsx @@ -0,0 +1,245 @@ +import { useEffect, useRef, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useTour } from './TourContext' +import { getTourStepByIndex, TOTAL_TOUR_STEPS } from './tourSteps' + +export default function TourOverlay() { + const navigate = useNavigate() + const { isTourActive, currentStep, nextStep, prevStep, skipTour, completeTour } = useTour() + + const [highlightBox, setHighlightBox] = useState(null) + const [tooltipPos, setTooltipPos] = useState({ top: 0, left: 0 }) + const [isVisible, setIsVisible] = useState(false) + const [isDarkMode, setIsDarkMode] = useState(false) + const overlayRef = useRef(null) + + const step = getTourStepByIndex(currentStep) + + // Sync theme configuration changes from document root attribute + useEffect(() => { + if (!isTourActive) return + const checkTheme = () => { + const theme = document.documentElement.getAttribute('data-theme') + setIsDarkMode(theme === 'dark') + } + + checkTheme() + const observer = new MutationObserver(checkTheme) + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }) + return () => observer.disconnect() + }, [isTourActive]) + + // Layout step dynamic paths + useEffect(() => { + if (!isTourActive || !step) { + setIsVisible(false) + return + } + + if (step.navigate) navigate(step.navigate) + + const timer = setTimeout(() => setIsVisible(true), 400) + return () => clearTimeout(timer) + }, [currentStep, step, isTourActive, navigate]) + + // Handle recalculation tracking bounds + useEffect(() => { + if (!isTourActive || !step) return + + const updatePositioning = () => { + if (!step.highlight || step.target === 'body') { + setHighlightBox(null) + return + } + + const element = document.querySelector(step.target) + if (!element) return setHighlightBox(null) + + const rect = element.getBoundingClientRect() + const padding = step.padding ?? 12 + + setHighlightBox({ + top: rect.top - padding + window.scrollY, + left: rect.left - padding + window.scrollX, + width: rect.width + padding * 2, + height: rect.height + padding * 2, + }) + + const tooltipWidth = 360, tooltipHeight = 280, gap = 16, paddingViewport = 20 + let top = 0, left = 0 + const position = step.position || 'right' + + switch (position) { + case 'right': + left = rect.right + window.scrollX + gap + top = rect.top + window.scrollY - (tooltipHeight - rect.height) / 2 + break + case 'left': + left = rect.left + window.scrollX - tooltipWidth - gap + top = rect.top + window.scrollY - (tooltipHeight - rect.height) / 2 + break + case 'bottom': + left = rect.left + window.scrollX + (rect.width - tooltipWidth) / 2 + top = rect.bottom + window.scrollY + gap + break + case 'top': + left = rect.left + window.scrollX + (rect.width - tooltipWidth) / 2 + top = rect.top + window.scrollY - tooltipHeight - gap + break + default: + left = (window.innerWidth - tooltipWidth) / 2 + top = Math.max(120, (window.innerHeight - tooltipHeight) / 2) + } + + if (left < paddingViewport) left = paddingViewport + if (left + tooltipWidth > window.innerWidth - paddingViewport) left = window.innerWidth - tooltipWidth - paddingViewport + top = Math.max(paddingViewport, Math.min(top, window.innerHeight - tooltipHeight - paddingViewport)) + + setTooltipPos({ top: Math.round(top), left: Math.round(left) }) + } + + const timer = setTimeout(updatePositioning, 500) + window.addEventListener('resize', updatePositioning) + window.addEventListener('scroll', updatePositioning) + + return () => { + clearTimeout(timer) + window.removeEventListener('resize', updatePositioning) + window.removeEventListener('scroll', updatePositioning) + } + }, [isTourActive, step]) + + // Hotkeys Listener + useEffect(() => { + if (!isTourActive) return + const handleKeyDown = (e) => { + if (e.key === 'ArrowRight' || e.key === ' ') { + e.preventDefault() + currentStep < TOTAL_TOUR_STEPS - 1 ? nextStep() : completeTour() + } else if (e.key === 'ArrowLeft' && currentStep > 0) { + e.preventDefault() + prevStep() + } else if (e.key === 'Escape') { + e.preventDefault() + skipTour() + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [isTourActive, currentStep, nextStep, prevStep, skipTour, completeTour]) + + if (!isTourActive || !step) return null + + const isLastStep = currentStep === TOTAL_TOUR_STEPS - 1 + const progress = ((currentStep + 1) / TOTAL_TOUR_STEPS) * 100 + + const maskColor = isDarkMode ? 'rgba(0, 0, 0, 0.25)' : 'rgba(0, 0, 0, 0.45)' + + return ( + <> + {/* Context Backdrop */} +
+ + {/* Spotlight Vector Wrap */} + {highlightBox && ( +
+ )} + + {/* Main Presentation Card */} +
+ {/* Header Row */} +
+ + STEP {currentStep + 1} OF {TOTAL_TOUR_STEPS} + + +
+ + {/* Content Elements */} +
+

{step.title}

+ {/* FIXED: whiteSpace handles parsing '\n' tags correctly */} +

+ {step.description} +

+
+ + {/* Micro Linear Tracker */} +
+
+
+ + {/* Balanced Actions Layout */} +
+ + + +
+
+ + ) +} \ No newline at end of file diff --git a/frontend/website/src/tour/TourStarter.jsx b/frontend/website/src/tour/TourStarter.jsx new file mode 100644 index 0000000..d4f657b --- /dev/null +++ b/frontend/website/src/tour/TourStarter.jsx @@ -0,0 +1,16 @@ +import { useTour } from './TourContext' + +/** + * TourStarter + * Button component to manually start the tour + * Can be placed in settings, profile, or anywhere user might want to re-run tour + */ +export default function TourStarter({ label = '🎯 Take the Tour', className = 'btn btn-secondary' }) { + const { startTour } = useTour() + + return ( + + ) +} \ No newline at end of file diff --git a/frontend/website/src/tour/tourSteps.jsx b/frontend/website/src/tour/tourSteps.jsx new file mode 100644 index 0000000..79b3b53 --- /dev/null +++ b/frontend/website/src/tour/tourSteps.jsx @@ -0,0 +1,133 @@ +/** + * tourSteps.js - Updated with Navigation + * Each step now navigates to the correct page/feature + */ + +export const TOUR_STEPS = [ + { + id: 'welcome', + title: '🚀 Welcome to AlgoSprint!', + description: 'You\'re all set! Let\'s take a quick tour to show you around.\n\nYou\'ll learn how to track your progress, find problems, compete with others, and master your coding skills.', + target: 'body', + position: 'center', + highlight: false, + navigate: null, // Stays on dashboard + }, + { + id: 'sidebar', + title: '🧭 Your Navigation Hub', + description: 'This is your sidebar - your central command center!\n\nFrom here you can:\n• View your dashboard\n• Practice problems\n• Join challenges\n• Connect with community\n• Check your profile', + target: '.sidebar', + position: 'right', + highlight: true, + padding: 8, + navigate: null, // Stay on dashboard to see sidebar + }, + { + id: 'streak', + title: '🔥 Build Your Streak', + description: 'This is your streak counter. Solve problems every day to maintain your streak!\n\nConsistency is the key to improvement. Your streak is visible to the community.', + target: '.sidebar-xp-bar', + position: 'right', + highlight: true, + padding: 8, + navigate: null, + }, + { + id: 'dashboard-overview', + title: '📊 Your Personal Dashboard', + description: 'This is your command center where you see all your stats at a glance:\n\n• Problems solved\n• Current streak & ranking\n• Performance metrics\n• Weak topics analysis\n• Progress charts', + target: '.page-content', + position: 'center', + highlight: true, + padding: 0, + navigate: '/dashboard', + }, + { + id: 'training', + title: '📚 Training Section', + description: 'Here\'s where you practice problems from LeetCode, Codeforces, and GeeksforGeeks.\n\nYou can:\n• Filter by difficulty\n• Search by topic\n• Track what you\'ve solved\n• Get curated recommendations\n• Solve problems from multiple platforms', + target: '.page-content', + position: 'center', + highlight: true, + padding: 0, + navigate: '/problems', // Navigate to problems page + }, + { + id: 'challenges', + title: '🏆 Take on Challenges', + description: 'Compete with other users in real-time challenges!\n\nSolve problems faster, aim for the leaderboard, and earn recognition.\n\nGreat for:\n• Testing your skills\n• Networking with coders', + target: '.page-content', + position: 'center', + highlight: true, + padding: 0, + navigate: '/challenges', + }, + { + id: 'community', + title: '👥 Connect with Community', + description: 'Share your progress, learn from others, and stay motivated!\n\nThe community section lets you:\n• See what others are working on\n• Share your solutions', + target: '.page-content', + position: 'center', + highlight: true, + padding: 0, + navigate: '/community', + }, + { + id: 'profile', + title: '⚙️ Your Profile & Settings', + description: 'Customize your experience here!\n\nYou can:\n• Update your bio and picture\n• Connect more platforms\n• View detailed statistics', + target: '.page-content', + position: 'center', + highlight: true, + padding: 0, + navigate: '/profile', + }, + { + id: 'topbar', + title: '🔔 Stay Updated', + description: 'The top bar keeps you in the loop with:\n\n• Notifications on new challenges\n• Updates on platform syncs\n• Quick access to search', + target: '.topbar', + position: 'bottom', + highlight: false, + padding: 8, + navigate: '/dashboard', + }, + { + id: 'tips', + title: '💡 Pro Tips to Get Started', + description: 'Here are some tips to maximize your learning:\n\n1. Solve one problem daily to maintain your streak\n2. Start with Easy problems, work your way up\n3. Join challenges to practice under pressure\n\nRemember: consistency beats intensity! 💪', + target: 'body', + position: 'center', + highlight: false, + navigate: '/dashboard', + }, + { + id: 'finish', + title: '✨ You\'re Ready to Code!', + description: 'You now have a complete overview of AlgoSprint.\n\nHere\'s what you should do next:\n\nHappy coding! 🚀', + target: 'body', + position: 'center', + highlight: false, + navigate: null, // User can decide where to go + }, +] + +/** + * Get tour step by ID + */ +export const getTourStep = (stepId) => { + return TOUR_STEPS.find(step => step.id === stepId) +} + +/** + * Get tour step by index + */ +export const getTourStepByIndex = (index) => { + return TOUR_STEPS[index] +} + +/** + * Total number of steps + */ +export const TOTAL_TOUR_STEPS = TOUR_STEPS.length \ No newline at end of file diff --git a/frontend/website/src/utils/themeStyles.js b/frontend/website/src/utils/themeStyles.js new file mode 100644 index 0000000..f3ee3e6 --- /dev/null +++ b/frontend/website/src/utils/themeStyles.js @@ -0,0 +1,42 @@ +/** Inline style tokens that follow light/dark via CSS variables in index.css */ + +export const CARD = { + background: 'var(--card-bg)', + backdropFilter: 'blur(24px)', + WebkitBackdropFilter: 'blur(24px)', + border: '1px solid var(--card-border)', + boxShadow: 'var(--surface-shadow)', + borderRadius: 18, + padding: 22, +} + +export const CARD_COMPACT = { + ...CARD, + borderRadius: 16, + padding: 22, + backdropFilter: 'blur(20px)', + WebkitBackdropFilter: 'blur(20px)', +} + +export const CHART_TOOLTIP = { + background: 'var(--chart-tooltip-bg)', + border: '1px solid var(--chart-tooltip-border)', + borderRadius: 10, + padding: '9px 14px', + backdropFilter: 'blur(16px)', + boxShadow: 'var(--shadow-md)', +} + +export const PROGRESS_TRACK = { + background: 'var(--xp-track-bg)', + borderRadius: 10, + overflow: 'hidden', +} + +export const HEATMAP_COLORS = [ + 'var(--heatmap-0)', + 'var(--heatmap-1)', + 'var(--heatmap-2)', + 'var(--heatmap-3)', + 'var(--heatmap-4)', +] From 419c9f0b70f3f6fc9334ef92b04db8abec04a7ce Mon Sep 17 00:00:00 2001 From: Anshika Guleria Date: Sat, 6 Jun 2026 19:22:19 +0530 Subject: [PATCH 2/2] fix: update api service --- frontend/website/src/services/api.js | 813 +++++++++++++++++++++------ 1 file changed, 640 insertions(+), 173 deletions(-) diff --git a/frontend/website/src/services/api.js b/frontend/website/src/services/api.js index 8a63ae6..a3ae23c 100755 --- a/frontend/website/src/services/api.js +++ b/frontend/website/src/services/api.js @@ -1,6 +1,6 @@ /** - * API Service Layer — Spring Boot Backend (port 8080) ONLY - * No mock API support - all calls go to real backend + * API Service Layer — Integrated with Spring Boot Backend (port 8080) + * Platform sync (LeetCode stats) is done server-side → stored in DB → served via /api/platforms/dashboard */ // Read from Vite's build-time env (`VITE_API_BASE`) on the deployed frontend; @@ -8,11 +8,23 @@ const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8080' // ── One-shot legacy-session cleanup ──────────────────────────────────────── - +// +// Runs once when this module loads. Users who logged in before the JWT-cookie +// migration have a stale `jwt_token` value sitting in localStorage. The new +// code path doesn't send that token anymore, so those sessions can't auth — +// but the UI still thinks they're logged in (because their email is also in +// localStorage), so they end up staring at a half-loaded dashboard. +// +// Detect that situation here and wipe everything so the user lands on the +// login screen on the very next render. They sign in once, get the cookie, +// and from then on the new flow takes over. They don't have to clear caches +// or open DevTools — it just works. ;(function migrateLegacyAuth() { if (typeof window === 'undefined') return try { if (localStorage.getItem('jwt_token') != null) { + // Wipe ALL persisted cache entries (any user prefix), too — the + // old session may have cached error envelopes against its email. try { const doomed = [] for (let i = 0; i < localStorage.length; i++) { @@ -30,8 +42,24 @@ const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8080' } catch { /* ignore — better to load app than crash on storage error */ } })() -/* ── Auth state — JWT lives in an HttpOnly cookie now ── */ +/* ── Auth state — JWT lives in an HttpOnly cookie now ── + * + * SECURITY NOTE + * The JWT is no longer stored in localStorage. JavaScript on this page + * literally cannot read it, so a successful XSS attack can't steal the + * token and DevTools' Storage tab won't show it either. The browser + * sends the auth cookie automatically on every request thanks to + * `credentials: 'include'` below; we don't have to attach a Bearer + * header at all. + * + * The kept-in-localStorage values (email, name, username) are NOT + * credentials — they're just UI hints (the avatar's initial letter, + * routing decisions, etc.). Even if leaked, an attacker can't auth + * with them. The session itself lives entirely in the HttpOnly cookie. + */ +// Backwards-compat shims — kept as no-ops so older code that imported +// these functions doesn't crash. Don't actually read or write tokens. export function getJWTToken() { return null } export function setJWTToken() { /* no-op: cookie is set by the server */ } @@ -43,22 +71,33 @@ export function setUsername(u) { if (u) localStorage.setItem('jwt_username', u); export function getUsername() { return localStorage.getItem('jwt_username') || '' } export function clearAuth() { + // Wipe dashboard cache FIRST — clearAllCache builds its prefix from + // the email, so we need the email still in storage at this point. try { clearAllCache() } catch { /* ignore */ } + // Best-effort: ask the server to clear the auth cookie. We don't await + // it — even if it fails (offline, server down), local UI state still + // gets cleared so the user sees themselves as logged out. try { fetch(`${API_BASE}/auth/logout`, { method: 'POST', credentials: 'include' }) .catch(() => {}) } catch { /* ignore */ } + // Strip any legacy JWT some older build may have left behind. localStorage.removeItem('jwt_token') localStorage.removeItem('jwt_email') localStorage.removeItem('jwt_name') localStorage.removeItem('jwt_username') localStorage.removeItem('algoledger_platforms') + // Profile pic cache — cleared via the shared helper so avatars update live. try { import('../utils/profilePic').then(m => m.clearProfilePic()).catch(() => {}) } catch { /* ignore */ } } export function isAuthenticated() { + // We can't read the HttpOnly cookie, so we use the email as a UI hint: + // if the user has logged in this browser, their email is in localStorage. + // Any actually-stale session gets caught by the 401 handler in + // authFetchJson, which clears local state and redirects to /login. return !!getUserEmail() } @@ -71,7 +110,7 @@ async function authFetch(path, options = {}) { return fetch(`${API_BASE}${path}`, { ...options, headers, - credentials: 'include', // send the auth cookie + credentials: 'include', // send the auth cookie cross-origin }) } @@ -80,7 +119,7 @@ export async function authFetchJson(path, options = {}) { try { const res = await authFetch(path, options) - // If token expired / invalid, backend returns JSON 401 + // If token expired / invalid, backend now returns JSON 401 — clear session and redirect if (res.status === 401) { const body = await res.text() let errMsg = 'Session expired. Please log in again.' @@ -90,6 +129,7 @@ export async function authFetchJson(path, options = {}) { return { ok: false, error: errMsg } } + // For non-2xx that still have a body, try to parse JSON const text = await res.text() if (!text) return { ok: false, error: `HTTP ${res.status} (empty response)` } let data @@ -130,6 +170,7 @@ export function savePlatformVerified(platform, username, verified, verifiedAt) { /** * Get full platform data including verification status. + * Returns { leetcode: { username, verified, verifiedAt }, ... } */ export function getLinkedPlatformsFull() { try { @@ -146,7 +187,14 @@ export function getLinkedPlatformsFull() { } catch { return {} } } -/* ── Platform ownership verification ── */ +/* ── Platform ownership verification (main backend) ── + * + * Two-step proof: (1) start — get target problem + startTime, + * (2) check — after the user submits on the real platform, we scan their + * recent submissions for an Accepted submission to the target problem + * with timestamp >= startTime. Endpoints are authenticated (onboarding + * happens post-login anyway). + */ /** Step 1 — confirm handle exists and receive the target problem. */ export async function verifyStart(platform, handle) { @@ -177,250 +225,669 @@ export async function verifyCheck(platform, handle, problemSlug, startTime) { if (r.ok && r.data.verified) return { success: true } return { success: false, - message: r.error || 'Verification failed', + message: (r.data && r.data.message) || r.error || + "Couldn't find your submission yet. Try again after you submit.", } } -export async function completeOnboarding(data) { - const r = await authFetchJson('/api/onboarding/complete', { - method: 'POST', - body: JSON.stringify(data), - }) - if (r.ok) return { success: true } - return { success: false, message: r.error || 'Failed to complete onboarding' } +/* ── Legacy shims for older callers. + * The demo-backend-on-port-4000 flow is gone; these forward to the + * main-backend verifyStart/verifyCheck with the same call signatures + * the old onboarding code expected. Safe to delete any time. */ +export async function initiateLeetCodeVerification(username) { + return verifyStart('leetcode', username) +} +export async function checkLeetCodeSubmission(username, startTime) { + return verifyCheck('leetcode', username, 'two-sum', startTime) +} +export async function initiateCodeforcesVerification(handle) { + return verifyStart('codeforces', handle) +} +export async function checkCodeforcesSubmission(handle, startTime) { + return verifyCheck('codeforces', handle, '4-A', startTime) } -/* ── Cache helpers for reducing API calls ── */ +/* ── Demo Backend — LeetCode Data API calls ── */ -function getCacheKey(email, endpoint) { - return `algoledger:cache:${email}:${endpoint}` +export async function deleteLeetCode(username) { + const res = await fetch(`${DEMO_API_BASE}/leetcode/delete-leetcode/${username}`, { method: 'DELETE' }) + return res.json() } -export function clearAllCache() { - const email = getUserEmail() - if (!email) return +export async function fetchLeetCode(username) { + const res = await fetch(`${DEMO_API_BASE}/leetcode/fetch/${username}`) + return res.json() +} + +/* ── Demo Backend — Codeforces Data API calls ── */ + +export async function deleteCodeforces(handle) { + const res = await fetch(`${DEMO_API_BASE}/codeforces/delete/${handle}`, { method: 'DELETE' }) + return res.json() +} + +export async function fetchCodeforces(handle) { + const res = await fetch(`${DEMO_API_BASE}/codeforces/fetch/${handle}`) + return res.json() +} + +/* ── Authentication API calls ── */ + +/** Register a new user — legacy direct path, kept for backward-compat. */ +export async function register(name, email, password) { + const res = await fetch(`${API_BASE}/auth/addNewUser`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, email, password, roles: 'ROLE_USER' }), + }) + const data = await res.text() + if (res.ok && name) setUserName(name) + return { success: res.ok, data, status: res.status } +} + +/* ── Email-verified signup (two-step) ── */ + +/** + * Step 1 — send an OTP to the user's email (name + @username + email + password). + * + * If the backend has verification disabled (feature flag off for dev / + * pre-domain-verification), the response will include a JWT directly and + * the frontend should skip the OTP step. We detect that here and stash + * the auth tokens immediately, mirroring what signupVerify does. + */ +export async function signupRequest(name, username, email, password) { try { - const doomed = [] - for (let i = 0; i < localStorage.length; i++) { - const k = localStorage.key(i) - if (k && k.startsWith(`algoledger:cache:${email}:`)) doomed.push(k) + const res = await fetch(`${API_BASE}/auth/signup/request`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, username, email, password }), + credentials: 'include', // accept the auth cookie if backend skips OTP + }) + const data = await res.json().catch(() => ({})) + if (res.ok) { + // Verification-disabled fast-path: server already created the + // account and dropped the auth cookie. Mirror the non-secret UI + // bits into localStorage; no token here on purpose. + if (data.verificationSkipped) { + setUserEmail(data.email || email) + if (data.name) setUserName(data.name) + if (data.username) setUsername(data.username) + } + return { ok: true, data } } - doomed.forEach(k => localStorage.removeItem(k)) - } catch { /* ignore */ } + return { ok: false, error: data.error || 'Could not send verification code' } + } catch (e) { + return { ok: false, error: 'Cannot connect to server. Please try again.' } + } } -export function setCacheEntry(endpoint, data, ttlSeconds = 300) { - const email = getUserEmail() - if (!email) return - const key = getCacheKey(email, endpoint) - const envelope = { - data, - expires: Date.now() + ttlSeconds * 1000, +/** Live check: is this @username free to grab? (public, no auth required) */ +export async function checkUsernameAvailable(u) { + try { + const res = await fetch(`${API_BASE}/auth/username/check?u=${encodeURIComponent(u)}`) + if (res.ok) return res.json() + return { available: false, reason: 'Couldn\'t check right now' } + } catch (e) { + return { available: false, reason: 'Couldn\'t check right now' } } +} + +/** Change the logged-in user's @username. */ +export async function updateMyUsername(username) { + return authFetchJson('/auth/me/username', { + method: 'PUT', + body: JSON.stringify({ username }), + }) +} + +/** Resend the OTP for an in-flight signup (respects server-side cooldown). */ +export async function signupResend(email) { try { - localStorage.setItem(key, JSON.stringify(envelope)) - } catch { /* ignore storage quota errors */ } + const res = await fetch(`${API_BASE}/auth/signup/resend`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }) + const data = await res.json().catch(() => ({})) + if (res.ok) return { ok: true, data } + return { ok: false, error: data.error || 'Could not resend code' } + } catch (e) { + return { ok: false, error: 'Cannot connect to server. Please try again.' } + } } -export function getCacheEntry(endpoint) { - const email = getUserEmail() - if (!email) return null - const key = getCacheKey(email, endpoint) +/** + * Step 2 — verify the OTP. On success, the backend creates the account and + * returns a JWT so we can drop the user straight into the app. + */ +export async function signupVerify(email, otp) { try { - const text = localStorage.getItem(key) - if (!text) return null - const envelope = JSON.parse(text) - if (Date.now() > envelope.expires) { - localStorage.removeItem(key) - return null + const res = await fetch(`${API_BASE}/auth/signup/verify`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, otp }), + credentials: 'include', // accept the auth cookie on success + }) + const data = await res.json().catch(() => ({})) + if (res.ok && data.email) { + setUserEmail(data.email || email) + if (data.name) setUserName(data.name) + if (data.username) setUsername(data.username) + return { ok: true, data } } - return envelope.data - } catch { return null } + return { ok: false, error: data.error || 'Verification failed' } + } catch (e) { + return { ok: false, error: 'Cannot connect to server. Please try again.' } + } } -/* ── Dashboard Stats ── */ +/** Login: exchange email + password for an HttpOnly auth cookie. */ +export async function login(email, password) { + try { + const res = await fetch(`${API_BASE}/auth/generateToken`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: email, password }), + credentials: 'include', // accept the auth cookie + }) + if (!res.ok) { + const errBody = await res.text().catch(() => '') + return { success: false, error: errBody || 'Invalid email or password' } + } + // Server set the auth cookie; we just persist UI hints. + setUserEmail(email) + // Fetch the user's name + profile pic — the cookie auto-authenticates. + try { + const meRes = await fetch(`${API_BASE}/auth/me`, { + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + }) + if (meRes.ok) { + const me = await meRes.json() + if (me.name) setUserName(me.name) + setUsername(me.username || '') + try { + const mod = await import('../utils/profilePic') + mod.setProfilePic(me.profilePic || null) + } catch { /* ignore */ } + } + } catch { /* best-effort, login still succeeded */ } + return { success: true, email } + } catch (err) { + return { success: false, error: 'Cannot connect to server. Please ensure the backend is running.' } + } +} -export async function getDashboardStats() { - const cached = getCacheEntry('dashboard') - if (cached) return { ok: true, data: cached } +/** Logout user */ +export function logout() { clearAuth() } - const r = await authFetchJson('/api/platforms/dashboard') - if (r.ok) { - setCacheEntry('dashboard', r.data, 600) - return r - } - return r +/** Fetch current user's profile from backend */ +export async function fetchMe() { + return authFetchJson('/auth/me') } -/* ── Missions / Practice ── */ +/** Update the authenticated user's display name */ +export async function updateProfile(name) { + return authFetchJson('/auth/me', { + method: 'PUT', + body: JSON.stringify({ name }), + }) +} -export async function getMissions() { - const cached = getCacheEntry('missions') - if (cached) return { ok: true, data: cached } +/** Change the authenticated user's password */ +export async function changePassword(currentPassword, newPassword) { + return authFetchJson('/auth/password', { + method: 'PUT', + body: JSON.stringify({ currentPassword, newPassword }), + }) +} - const r = await authFetchJson('/api/missions') - if (r.ok) { - setCacheEntry('missions', r.data, 600) - return r - } - return r +/** Permanently delete the authenticated user's account */ +export async function deleteAccount() { + return authFetchJson('/auth/me', { method: 'DELETE' }) } -/* ── Community ── */ +/** Upload a new profile picture (base64 data URL). */ +export async function updateProfilePicture(dataUrl) { + return authFetchJson('/auth/me/picture', { + method: 'PUT', + body: JSON.stringify({ profilePic: dataUrl }), + }) +} -export async function getCommunityPosts(limit = 10, offset = 0) { - const r = await authFetchJson(`/api/community/posts?limit=${limit}&offset=${offset}`) - return r +/** Remove the authenticated user's profile picture. */ +export async function removeProfilePicture() { + return authFetchJson('/auth/me/picture', { method: 'DELETE' }) } -export async function getCommunityPost(postId) { - const r = await authFetchJson(`/api/community/posts/${postId}`) - return r +/* ── Notification preferences ── */ + +/** Fetch the user's reminder email preferences. */ +export async function fetchNotificationPrefs() { + return authFetchJson('/auth/me/notifications') } -export async function createCommunityPost(content, topic) { - const r = await authFetchJson('/api/community/posts', { - method: 'POST', - body: JSON.stringify({ content, topic }), +/** Save { enabled, reminderTime 'HH:mm', reminderTimezone 'Area/City' }. */ +export async function updateNotificationPrefs(prefs) { + return authFetchJson('/auth/me/notifications', { + method: 'PUT', + body: JSON.stringify(prefs), }) - return r } -export async function likeCommunityPost(postId) { - const r = await authFetchJson(`/api/community/posts/${postId}/like`, { +/* ── Platform linking (stored in DB via backend) ── */ + +/** + * Link a platform account. Triggers an immediate sync from the platform API. + * platform: 'leetcode' | 'codeforces' + */ +export async function linkPlatform(platform, username) { + const res = await authFetch('/api/platforms/link', { method: 'POST', + body: JSON.stringify({ platform, username }), }) - return r + const data = await res.json() + if (res.ok) { + // Mirror in localStorage for instant UI feedback + const platforms = getLinkedPlatforms() + platforms[platform] = username + savePlatforms(platforms) + return { success: true, data } + } + // 409 = this coding account is already owned by another user + if (res.status === 409) { + return { success: false, conflict: true, error: data.error || 'This account is already linked to another user.' } + } + return { success: false, error: data.error || 'Failed to link platform' } } -export async function unlikeCommunityPost(postId) { - const r = await authFetchJson(`/api/community/posts/${postId}/unlike`, { - method: 'POST', - }) - return r +/** Verify a LeetCode username exists (public endpoint) */ +export async function verifyLeetCodeUsername(username) { + try { + const res = await fetch(`${API_BASE}/api/leetcode/submissions/${encodeURIComponent(username)}`) + if (res.ok) { + const data = await res.json() + return { valid: true, data } + } + return { valid: false, error: 'Username not found' } + } catch (e) { + return { valid: false, error: e.message } + } } -/* ── Contests ── */ +/** Verify a Codeforces handle exists (public endpoint) */ +export async function verifyCodeforcesHandle(handle) { + try { + const res = await fetch(`${API_BASE}/api/codeforces/user/${encodeURIComponent(handle)}`) + if (res.ok) { + const data = await res.json() + return { valid: data.exists, data } + } + return { valid: false, error: 'Handle not found' } + } catch (e) { + return { valid: false, error: e.message } + } +} -export async function getContests() { - const cached = getCacheEntry('contests') - if (cached) return { ok: true, data: cached } +/* ── Dashboard data (served from DB, synced from real platform APIs) ── */ + +/* + * ─── Persistent client-side cache ───────────────────────────────────────── + * + * Dashboard data (stats, calendar heatmap, recent submissions) is expensive + * on the backend — a cold-started free-tier instance plus live-sync against + * LeetCode / Codeforces APIs can take several seconds per call. Re-paying + * that cost on every page load (or every tab-switch) is what was making the + * post-onboarding "welcome to your dashboard" moment feel slow. + * + * The contract now: + * - First read after login → fetch from server, store in localStorage. + * - Every subsequent read → served instantly from localStorage. + * - User clicks "Sync" → invalidate cache + force a fresh fetch. + * - User logs out → cache wiped (see clearAuth). + * + * In-memory `inflight` Map still dedupes concurrent callers in the same + * tick so Sidebar + TopBar + page don't each hit the same endpoint thrice. + * + * Cache keys are scoped per-user (via the JWT email) so switching accounts + * can't leak another user's numbers onto the page. + */ +const _inflight = new Map() // key -> Promise (dedup concurrent callers) - const r = await authFetchJson('/api/contests') - if (r.ok) { - setCacheEntry('contests', r.data, 3600) - return r - } - return r +function _cacheKey(key) { + const email = getUserEmail() || 'anon' + return `algoledger:cache:${email}:${key}` +} +function _readPersisted(key) { + try { + const raw = localStorage.getItem(_cacheKey(key)) + if (!raw) return null + return JSON.parse(raw) // { fetchedAt, value } + } catch { return null } +} +function _writePersisted(key, value) { + try { + localStorage.setItem( + _cacheKey(key), + JSON.stringify({ fetchedAt: Date.now(), value }) + ) + } catch { /* quota exceeded — degrade gracefully */ } +} +function _removePersisted(key) { + try { localStorage.removeItem(_cacheKey(key)) } catch { /* ignore */ } } -/* ── Problems ── */ +/** + * Persistent cached fetch. + * - forceRefresh=true → always hit the network, refresh the cache. + * - otherwise → return cached value if present; else fetch once. + */ +function cachedFetch(key, fetcher, { forceRefresh = false } = {}) { + if (!forceRefresh) { + const persisted = _readPersisted(key) + if (persisted) return Promise.resolve(persisted.value) + const pending = _inflight.get(key) + if (pending) return pending + } + const p = fetcher() + .then(value => { + const isErrorEnvelope = value && typeof value === 'object' && value.success === false + + // Self-heal on 401: cookie expired / missing / revoked. The user + // can't recover by clicking Sync (they wouldn't even know to try) + // — we wipe local state and bounce to /login automatically so they + // can sign in again without doing anything in DevTools. + if (isErrorEnvelope && typeof value.error === 'string' && /^HTTP 401\b/.test(value.error)) { + _inflight.delete(key) + try { clearAuth() } catch { /* ignore */ } + if (typeof window !== 'undefined' && window.location.pathname !== '/login') { + try { window.location.href = '/login' } catch { /* ignore */ } + } + return value + } + + // Only cache SUCCESSFUL responses. Caching `{ success: false, ... }` + // would freeze the UI in an error state until the user manually + // synced — which is exactly the bug we keep hitting after auth + // changes. So skip the cache write on any error envelope. + if (!isErrorEnvelope) _writePersisted(key, value) + _inflight.delete(key) + return value + }) + .catch(err => { + // Don't poison the cache with thrown errors either. + _inflight.delete(key) + throw err + }) + _inflight.set(key, p) + return p +} + +/** Timestamp (ms since epoch) of the cached entry, or null if not cached. */ +export function getCacheTimestamp(key) { + const entry = _readPersisted(key) + return entry?.fetchedAt ?? null +} + +/** Most recent sync time across dashboard + calendar + submissions, or null. */ +export function getLastSyncedAt() { + const stamps = [ + getCacheTimestamp('dashboard'), + getCacheTimestamp('calendar'), + getCacheTimestamp('submissions'), + ].filter(Boolean) + return stamps.length ? Math.min(...stamps) : null +} + +/** Invalidate cached dashboard data — call on sync/logout so the next read is fresh. */ +export function invalidateDashboardCache() { + _removePersisted('dashboard') + _removePersisted('calendar') + _removePersisted('submissions') + _inflight.delete('dashboard') + _inflight.delete('calendar') + _inflight.delete('submissions') +} -export async function getProblems(filters = {}) { - const params = new URLSearchParams(filters) - const r = await authFetchJson(`/api/problems?${params}`) - return r +/** + * Nuke every cache key for the current user. Called from clearAuth() so + * logging out doesn't leave stale numbers in storage. + */ +export function clearAllCache() { + try { + const email = getUserEmail() || 'anon' + const prefix = `algoledger:cache:${email}:` + const doomed = [] + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i) + if (k && k.startsWith(prefix)) doomed.push(k) + } + doomed.forEach(k => localStorage.removeItem(k)) + } catch { /* ignore */ } + _inflight.clear() } -/* ── Challenges ── */ +/** + * Fetch dashboard stats for the logged-in user. + * Returns: { totalSolved, easySolved, mediumSolved, hardSolved, + * currentStreak, longestStreak, platforms[], topics[], linkedPlatforms[] } + */ +export function fetchDashboardData(opts) { + return cachedFetch('dashboard', async () => { + const res = await authFetch('/api/platforms/dashboard') + if (res.ok) return { success: true, data: await res.json() } + return { success: false, error: `HTTP ${res.status}` } + }, opts) +} -export async function getChallenges() { - const cached = getCacheEntry('challenges') - if (cached) return { ok: true, data: cached } +export function fetchCalendarData(opts) { + return cachedFetch('calendar', async () => { + const res = await authFetch('/api/platforms/calendar') + if (res.ok) return { success: true, data: await res.json() } + return { success: false, error: `HTTP ${res.status}` } + }, opts) +} - const r = await authFetchJson('/api/challenges') - if (r.ok) { - setCacheEntry('challenges', r.data, 600) - return r +/** + * Force-sync all linked platforms from their live APIs → update DB → return fresh stats. + * Invalidates the client cache so the next read fetches the freshly-synced data. + */ +export async function syncAllPlatforms() { + const res = await authFetch('/api/platforms/sync', { method: 'POST' }) + invalidateDashboardCache() + if (res.ok) { + return { success: true, data: await res.json() } } - return r + return { success: false, error: `HTTP ${res.status}` } +} + +/* ── Legacy helpers (kept for backward compat with OnboardingPage) ── */ + +export async function addLeetCode(username) { + return linkPlatform('leetcode', username) } -export async function getUserChallenges() { - const r = await authFetchJson('/api/challenges/mine') - return r +export async function addCodeforces(handle) { + return linkPlatform('codeforces', handle) } -/* ── Profile ── */ +/** @deprecated Use fetchDashboardData() instead */ +export async function fetchAllPlatformData() { + return fetchDashboardData() +} -export async function getUserProfile() { - const cached = getCacheEntry('profile') - if (cached) return { ok: true, data: cached } - const r = await authFetchJson('/api/user/profile') - if (r.ok) { - setCacheEntry('profile', r.data, 600) - return r - } - return r +export function fetchLeetCodeSubmissions(username, opts) { + return cachedFetch('submissions', async () => { + try { + const res = await fetch(`${API_BASE}/api/leetcode/submissions/${encodeURIComponent(username)}`) + if (res.ok) { + const data = await res.json() + return { success: true, data: data.submissions || [] } + } + return { success: false, error: 'Failed to fetch' } + } catch (e) { + return { success: false, error: e.message } + } + }, opts) } -export async function updateUserProfile(updates) { - const r = await authFetchJson('/api/user/profile', { - method: 'PUT', - body: JSON.stringify(updates), - }) - if (r.ok) { - clearAllCache() // Invalidate profile cache - } - return r +/* ───────────────────────────────────────────── + CHALLENGE / CONTEST APIs +───────────────────────────────────────────── */ + +/** + * Create a challenge. First arg accepts either: + * - a string (legacy: opponent's email address), or + * - an object (preferred): { opponentUsername } or { opponentEmail } + */ +export async function createChallenge(opponent, contestType, customCounts = {}) { + try { + const ref = typeof opponent === 'string' + ? { opponentEmail: opponent } + : (opponent || {}) + const body = { ...ref, contestType, ...customCounts } + const res = await authFetch('/challenges', { + method: 'POST', + body: JSON.stringify(body), + }) + const data = await res.json() + if (res.ok) return { success: true, data } + return { success: false, error: data.error || 'Failed to create challenge' } + } catch (e) { return { success: false, error: e.message } } +} + +export async function getChallenge(id) { + try { + const res = await authFetch(`/challenges/${id}`) + if (res.ok) return { success: true, data: await res.json() } + return { success: false, error: 'Not found' } + } catch (e) { return { success: false, error: e.message } } } -/* ── Recommendations ── */ +export async function acceptChallenge(id) { + try { + const res = await authFetch(`/challenges/${id}/accept`, { method: 'POST' }) + const data = await res.json() + if (res.ok) return { success: true, data } + return { success: false, error: data.error || 'Failed' } + } catch (e) { return { success: false, error: e.message } } +} -export async function getRecommendations() { - const cached = getCacheEntry('recommendations') - if (cached) return { ok: true, data: cached } +export async function declineChallenge(id) { + try { + const res = await authFetch(`/challenges/${id}/decline`, { method: 'POST' }) + const data = await res.json() + if (res.ok) return { success: true, data } + return { success: false, error: data.error || 'Failed' } + } catch (e) { return { success: false, error: e.message } } +} - const r = await authFetchJson('/api/recommendations') - if (r.ok) { - setCacheEntry('recommendations', r.data, 1200) - return r - } - return r +export async function fetchMyChallenges() { + try { + const res = await authFetch('/challenges/mine') + if (res.ok) return { success: true, data: await res.json() } + return { success: false, error: 'Failed' } + } catch (e) { return { success: false, error: e.message } } } -/* ── Notifications ── */ +export async function fetchInvitations() { + try { + const res = await authFetch('/challenges/invitations') + if (res.ok) return { success: true, data: await res.json() } + return { success: false, error: 'Failed' } + } catch (e) { return { success: false, error: e.message } } +} -export async function getUnreadNotificationCount() { - const r = await authFetchJson('/api/notifications/unread-count') - return r +export async function fetchLeaderboard(id) { + try { + const res = await authFetch(`/challenges/${id}/leaderboard`) + if (res.ok) return { success: true, data: await res.json() } + return { success: false, error: 'Failed' } + } catch (e) { return { success: false, error: e.message } } } -export async function getNotifications() { - const r = await authFetchJson('/api/notifications') - return r +export async function finishChallenge(id) { + try { + const res = await authFetch(`/challenges/${id}/finish`, { method: 'POST' }) + const data = await res.json() + if (res.ok) return { success: true, data } + return { success: false, error: data.error || 'Failed' } + } catch (e) { return { success: false, error: e.message } } } -/* ── Authentication ── */ +/* ───────────────────────────────────────────── + COMMUNITY / POSTS APIs +───────────────────────────────────────────── */ -export async function loginWithEmailPassword(email, password) { - const r = await authFetchJson('/auth/login', { +export async function fetchFeed(page = 0, size = 10) { + return authFetchJson(`/api/posts?page=${page}&size=${size}`) +} + +export async function fetchFeedByTopic(topic, page = 0, size = 10) { + return authFetchJson(`/api/posts/topic/${encodeURIComponent(topic)}?page=${page}&size=${size}`) +} + +export async function fetchPost(id) { + return authFetchJson(`/api/posts/${id}`) +} + +export async function createPost(title, topic, content) { + return authFetchJson('/api/posts', { method: 'POST', - body: JSON.stringify({ email, password }), + body: JSON.stringify({ title, topic, content }), }) - if (r.ok) { - setUserEmail(r.data.email) - setUserName(r.data.name) - setUsername(r.data.username) - } - return r } -export async function signupWithEmail(name, email, password, username) { - const r = await authFetchJson('/auth/signup/request', { +export async function toggleLike(id) { + return authFetchJson(`/api/posts/${id}/like`, { method: 'POST' }) +} + +export async function deletePost(id) { + return authFetchJson(`/api/posts/${id}`, { method: 'DELETE' }) +} + +export async function fetchMyPosts() { + return authFetchJson('/api/posts/mine') +} + +/* ── Save posts ── */ +export async function savePost(id) { return authFetchJson(`/api/posts/${id}/save`, { method: 'POST' }) } +export async function unsavePost(id) { return authFetchJson(`/api/posts/${id}/save`, { method: 'DELETE' }) } +export async function fetchSavedPosts() { return authFetchJson('/api/posts/saved') } + +/* ── Follow graph ── */ +export async function followUser(username) { return authFetchJson(`/api/follow/${encodeURIComponent(username)}`, { method: 'POST' }) } +export async function unfollowUser(username) { return authFetchJson(`/api/follow/${encodeURIComponent(username)}`, { method: 'DELETE' }) } +export async function fetchFollowStatus(username) { return authFetchJson(`/api/follow/status/${encodeURIComponent(username)}`) } +export async function fetchFollowers(username) { return authFetchJson(`/api/follow/${encodeURIComponent(username)}/followers`) } +export async function fetchFollowing(username) { return authFetchJson(`/api/follow/${encodeURIComponent(username)}/following`) } + +/* ── In-app notifications ── + * + * The bell in the TopBar polls fetchUnreadCount every minute (cheap, just + * a number) and fires fetchNotifications when the user actually opens the + * dropdown. We deliberately DON'T cache these — notifications need to feel + * live, and the payloads are small. */ +export async function fetchNotifications(page = 0, size = 20) { + return authFetchJson(`/api/notifications?page=${page}&size=${size}`) +} +export async function fetchUnreadNotifCount() { + return authFetchJson('/api/notifications/unread-count') +} +export async function markAllNotificationsRead() { + return authFetchJson('/api/notifications/mark-all-read', { method: 'POST' }) +} +/** Admin only — broadcast a SYSTEM notification to every registered user. + * Returns 403 unless the caller's email is in `app.admin.emails` server-side. */ +export async function broadcastNotification({ title, message, link }) { + return authFetchJson('/api/notifications/broadcast', { method: 'POST', - body: JSON.stringify({ name, email, password, username }), + body: JSON.stringify({ title, message, link }), }) - return r } -export async function getCurrentUser() { - const r = await authFetchJson('/auth/me') - if (r.ok) { - setUserEmail(r.data.email) - setUserName(r.data.name) - setUsername(r.data.username) - } - return r +// ── Recommendations ──────────────────────────────────────────────────────────── +export async function completeDailyMission() { + return authFetchJson('/recommendations/daily-mission/complete', { method: 'POST' }) } \ No newline at end of file