diff --git a/apps/web/src/app/app/blindspot-map/page.tsx b/apps/web/src/app/app/blindspot-map/page.tsx new file mode 100644 index 0000000..bd5b9c4 --- /dev/null +++ b/apps/web/src/app/app/blindspot-map/page.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { PageHeader } from "@/components/app/page-header"; +import { LoadingPanel } from "@/components/app/loading-panel"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { useBlindspotsQuery } from "@/lib/mock-data/hooks"; + +export default function BlindspotMapPage() { + const { data: blindspots, isLoading } = useBlindspotsQuery(); + + if (isLoading || !blindspots) return ; + + return ( + <> + +
+ {blindspots.map((blindspot) => ( + + +
+ {blindspot.concept} + 70 ? "warning" : "secondary"}>{blindspot.severity}% risk +
+
+ + +
+
+

Evidence

+

{blindspot.evidence}

+
+
+

Next action

+

{blindspot.nextAction}

+
+
+
+
+ ))} +
+ + ); +} diff --git a/apps/web/src/app/app/dashboard/page.tsx b/apps/web/src/app/app/dashboard/page.tsx new file mode 100644 index 0000000..4f9f725 --- /dev/null +++ b/apps/web/src/app/app/dashboard/page.tsx @@ -0,0 +1,79 @@ +"use client"; + +import Link from "next/link"; +import { ArrowRight, Clock, Target, Trophy } from "lucide-react"; +import { PageHeader } from "@/components/app/page-header"; +import { LoadingPanel } from "@/components/app/loading-panel"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { useDashboardQuery } from "@/lib/mock-data/hooks"; +import { IRSRadarChart } from "@/components/features/irs-radar-chart"; +import { Leaderboard } from "@/components/features/leaderboard"; +import { StreakTracker } from "@/components/features/streak-tracker"; + +export default function DashboardPage() { + const { data: dashboard, isLoading } = useDashboardQuery(); + + if (isLoading || !dashboard) return ; + + const stats = [ + { label: "IRS", value: dashboard.user.irs, copy: "Irreplaceability score", icon: Trophy }, + { label: "Rank", value: `#${dashboard.user.rank}`, copy: "War Room placement", icon: Target }, + { label: "Focus", value: "34m", copy: "Next module estimate", icon: Clock }, + ]; + + return ( + <> + Resume module} + /> +
+ {stats.map((stat) => { + const Icon = stat.icon; + return ( + + + + {stat.label} + + + + +

{stat.value}

+

{stat.copy}

+
+
+ ); + })} +
+
+ + + {dashboard.activeTrack.title} + + +

{dashboard.activeTrack.description}

+ +
+ {dashboard.activeTrack.modules.map((module) => ( + +

{module.title}

+

{module.summary}

+ + ))} +
+
+
+ +
+
+ + +
+ + ); +} diff --git a/apps/web/src/app/app/layout.tsx b/apps/web/src/app/app/layout.tsx new file mode 100644 index 0000000..3ba80ba --- /dev/null +++ b/apps/web/src/app/app/layout.tsx @@ -0,0 +1,5 @@ +import { AppShell } from "@/components/app/app-shell"; + +export default function ProductLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/web/src/app/app/page.tsx b/apps/web/src/app/app/page.tsx new file mode 100644 index 0000000..2a301be --- /dev/null +++ b/apps/web/src/app/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function AppIndexPage() { + redirect("/app/dashboard"); +} diff --git a/apps/web/src/app/app/profile/page.tsx b/apps/web/src/app/app/profile/page.tsx new file mode 100644 index 0000000..e64d44a --- /dev/null +++ b/apps/web/src/app/app/profile/page.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { PageHeader } from "@/components/app/page-header"; +import { LoadingPanel } from "@/components/app/loading-panel"; +import { IRSRadarChart } from "@/components/features/irs-radar-chart"; +import { StreakTracker } from "@/components/features/streak-tracker"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useProfileQuery } from "@/lib/mock-data/hooks"; + +export default function ProfilePage() { + const { data: profile, isLoading } = useProfileQuery(); + + if (isLoading || !profile) return ; + + return ( + <> + IRS {profile.irs}} /> +
+ + +
+
+ + + Session display + + +

Name: {profile.name}

+

Role: {profile.role}

+

Completed modules: {profile.completedModules}

+
+
+ + + Recent modules + + + {profile.recent.map((item) => ( +
{item}
+ ))} +
+
+
+ + ); +} diff --git a/apps/web/src/app/app/tracks/[trackId]/modules/[moduleId]/page.tsx b/apps/web/src/app/app/tracks/[trackId]/modules/[moduleId]/page.tsx new file mode 100644 index 0000000..2e7d080 --- /dev/null +++ b/apps/web/src/app/app/tracks/[trackId]/modules/[moduleId]/page.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { PageHeader } from "@/components/app/page-header"; +import { LoadingPanel } from "@/components/app/loading-panel"; +import { Badge } from "@/components/ui/badge"; +import { ModulePlayer } from "@/components/features/module-player"; +import { useModuleQuery } from "@/lib/mock-data/hooks"; + +export default function ModulePage({ params }: { params: { trackId: string; moduleId: string } }) { + const { data, isLoading } = useModuleQuery(params.trackId, params.moduleId); + + if (isLoading || !data) return ; + + return ( + <> + {data.module.concepts.map((item) => {item})}} + /> + + + ); +} diff --git a/apps/web/src/app/app/tracks/page.tsx b/apps/web/src/app/app/tracks/page.tsx new file mode 100644 index 0000000..bf879f3 --- /dev/null +++ b/apps/web/src/app/app/tracks/page.tsx @@ -0,0 +1,52 @@ +"use client"; + +import Link from "next/link"; +import { ArrowRight } from "lucide-react"; +import { PageHeader } from "@/components/app/page-header"; +import { LoadingPanel } from "@/components/app/loading-panel"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { useTracksQuery } from "@/lib/mock-data/hooks"; + +export default function TracksPage() { + const { data: tracks, isLoading } = useTracksQuery(); + + if (isLoading || !tracks) return ; + + return ( + <> + +
+ {tracks.map((track) => ( + + +
+ {track.title} + {track.difficulty} +
+
+ +

{track.description}

+
+
+ Progress + {track.progress}% +
+ +
+
+ {track.modules.map((module) => ( + + {module.title} + + + ))} +
+
+
+ ))} +
+ + ); +} diff --git a/apps/web/src/app/app/war-room/page.tsx b/apps/web/src/app/app/war-room/page.tsx new file mode 100644 index 0000000..c0922a4 --- /dev/null +++ b/apps/web/src/app/app/war-room/page.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { PageHeader } from "@/components/app/page-header"; +import { LoadingPanel } from "@/components/app/loading-panel"; +import { Badge } from "@/components/ui/badge"; +import { WarRoomLive } from "@/components/features/war-room-live"; +import { useWarRoomQuery } from "@/lib/mock-data/hooks"; + +export default function WarRoomPage() { + const { data, isLoading } = useWarRoomQuery(); + + if (isLoading || !data) return ; + + return ( + <> + {data.room.members} members live} + /> + + + ); +} diff --git a/apps/web/src/app/auth/signin/page.tsx b/apps/web/src/app/auth/signin/page.tsx new file mode 100644 index 0000000..7004db3 --- /dev/null +++ b/apps/web/src/app/auth/signin/page.tsx @@ -0,0 +1,46 @@ +"use client"; + +import Link from "next/link"; +import { Github, Mail } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { useAuthStore } from "@/stores/auth-store"; + +export default function SignInPage() { + const { signIn } = useAuthStore(); + + return ( +
+ + + + UV + UnVibe + + Sign in +

Use OAuth later, or enter the mock workspace now.

+
+ + + +
+ + +
+

+ New here? Create an account +

+
+
+
+ ); +} diff --git a/apps/web/src/app/auth/signup/page.tsx b/apps/web/src/app/auth/signup/page.tsx new file mode 100644 index 0000000..7235c17 --- /dev/null +++ b/apps/web/src/app/auth/signup/page.tsx @@ -0,0 +1,47 @@ +"use client"; + +import Link from "next/link"; +import { Github, Mail } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { useAuthStore } from "@/stores/auth-store"; + +export default function SignUpPage() { + const { signIn } = useAuthStore(); + + return ( +
+ + + + UV + UnVibe + + Create account +

Mock onboarding keeps frontend work unblocked.

+
+ + + +
+ + + +
+

+ Already training? Sign in +

+
+
+
+ ); +} diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 8abdb15..d1209a4 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -4,65 +4,65 @@ @layer base { :root { - --background: 0 0% 100%; - --foreground: 222.2 84% 4.9%; + --background: 210 25% 98%; + --foreground: 218 33% 10%; --card: 0 0% 100%; - --card-foreground: 222.2 84% 4.9%; + --card-foreground: 218 33% 10%; --popover: 0 0% 100%; - --popover-foreground: 222.2 84% 4.9%; + --popover-foreground: 218 33% 10%; - --primary: 222.2 47.4% 11.2%; - --primary-foreground: 210 40% 98%; + --primary: 188 91% 35%; + --primary-foreground: 190 90% 98%; - --secondary: 210 40% 96.1%; - --secondary-foreground: 222.2 47.4% 11.2%; + --secondary: 214 32% 91%; + --secondary-foreground: 218 33% 12%; - --muted: 210 40% 96.1%; - --muted-foreground: 215.4 16.3% 46.9%; + --muted: 215 28% 92%; + --muted-foreground: 216 14% 38%; - --accent: 210 40% 96.1%; - --accent-foreground: 222.2 47.4% 11.2%; + --accent: 41 96% 56%; + --accent-foreground: 32 95% 12%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 210 40% 98%; - --border: 214.3 31.8% 91.4%; - --input: 214.3 31.8% 91.4%; - --ring: 222.2 84% 4.9%; + --border: 214 20% 84%; + --input: 214 20% 84%; + --ring: 188 91% 35%; --radius: 0.5rem; } .dark { - --background: 222.2 84% 4.9%; - --foreground: 210 40% 98%; + --background: 220 24% 6%; + --foreground: 213 31% 91%; - --card: 222.2 84% 4.9%; - --card-foreground: 210 40% 98%; + --card: 220 20% 10%; + --card-foreground: 213 31% 91%; - --popover: 222.2 84% 4.9%; - --popover-foreground: 210 40% 98%; + --popover: 220 20% 10%; + --popover-foreground: 213 31% 91%; - --primary: 210 40% 98%; - --primary-foreground: 222.2 47.4% 11.2%; + --primary: 187 85% 52%; + --primary-foreground: 220 24% 6%; - --secondary: 217.2 32.6% 17.5%; - --secondary-foreground: 210 40% 98%; + --secondary: 218 18% 16%; + --secondary-foreground: 213 31% 91%; - --muted: 217.2 32.6% 17.5%; - --muted-foreground: 215 20.2% 65.1%; + --muted: 218 18% 14%; + --muted-foreground: 215 18% 63%; - --accent: 217.2 32.6% 17.5%; - --accent-foreground: 210 40% 98%; + --accent: 42 94% 58%; + --accent-foreground: 220 24% 6%; --destructive: 0 62.8% 30.6%; --destructive-foreground: 210 40% 98%; - --border: 217.2 32.6% 17.5%; - --input: 217.2 32.6% 17.5%; - --ring: 212.7 26.8% 83.9%; + --border: 218 16% 21%; + --input: 218 16% 21%; + --ring: 187 85% 52%; } } @@ -71,6 +71,23 @@ @apply border-border; } body { - @apply bg-background text-foreground; + @apply text-foreground; + } + + ::selection { + @apply bg-primary/30 text-foreground; + } +} + +@layer utilities { + .surface-grid { + background-image: + linear-gradient(hsl(var(--border) / 0.3) 1px, transparent 1px), + linear-gradient(90deg, hsl(var(--border) / 0.3) 1px, transparent 1px); + background-size: 28px 28px; + } + + .text-balance { + text-wrap: balance; } } diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index d75a899..eb595a6 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import localFont from "next/font/local"; import "./globals.css"; import Providers from "./providers"; +import { ThemeProvider } from "@/components/app/theme-provider"; const geistSans = localFont({ src: "./fonts/GeistVF.woff", @@ -25,12 +26,14 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + - {children} + + {children} + diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 6fe62d1..ad0991d 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,101 +1,120 @@ -import Image from "next/image"; +"use client"; -export default function Home() { +import Link from "next/link"; +import { ArrowRight, Code2, GitBranch, Radar, ShieldQuestion, Swords } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { ThemeController } from "@/components/app/theme-controller"; + +const signals = [ + { label: "Decode", value: "annotate code intent", icon: Code2 }, + { label: "Rebuild", value: "write from memory", icon: GitBranch }, + { label: "Defend", value: "answer under pressure", icon: ShieldQuestion }, +]; + +const featureCards = [ + { title: "Blindspots", copy: "See weak concepts before they become habits.", icon: Radar }, + { title: "War Rooms", copy: "Practice with peers in live competitive rooms.", icon: Swords }, + { title: "Code memory", copy: "Submit rebuilds and compare against source.", icon: Code2 }, +]; + +export default function LandingPage() { return ( -
-
- Next.js logo -
    -
  1. - Get started by editing{" "} - - src/app/page.tsx - - . -
  2. -
  3. Save and see your changes instantly.
  4. -
+
+
+ + +
+
+ + AI learning loop for builders + +

+ Learn code until you can explain every decision. +

+

+ UnVibe turns passive tutorials into an active sequence: read production code, rebuild it, then defend your reasoning in live sessions. +

+
+ + +
+
+ +
+
+
+

live module

+

Auth guard rebuild

+
+ IRS 82 +
+
+ {signals.map((signal, index) => { + const Icon = signal.icon; + return ( +
+
+ +
+
+
+

{signal.label}

+ 0{index + 1} +
+

{signal.value}

+
+
+ ); + })} +
+
+

const session = defend(rebuild);

+

score.update(session.reasoning);

+

{"// next question streams into War Room"}

+
+
+
-
- - Vercel logomark - Deploy now - - - Read our docs - +
+ {featureCards.map((feature) => { + const Icon = feature.icon; + return ( +
+ +

{feature.title}

+

{feature.copy}

+
+ ); + })}
-
- -
+ + ); } diff --git a/apps/web/src/components/app/app-shell.tsx b/apps/web/src/components/app/app-shell.tsx new file mode 100644 index 0000000..5bdd5d0 --- /dev/null +++ b/apps/web/src/components/app/app-shell.tsx @@ -0,0 +1,96 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { BarChart3, LayoutDashboard, Map, RadioTower, Route, UserRound } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { ThemeController } from "./theme-controller"; +import { useAuthStore } from "@/stores/auth-store"; +import { Button } from "@/components/ui/button"; + +const nav = [ + { href: "/app/dashboard", label: "Dashboard", icon: LayoutDashboard }, + { href: "/app/tracks", label: "Tracks", icon: Route }, + { href: "/app/war-room", label: "War Room", icon: RadioTower }, + { href: "/app/blindspot-map", label: "Blindspots", icon: Map }, + { href: "/app/profile", label: "Profile", icon: UserRound }, +]; + +export function AppShell({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const { user, signOut } = useAuthStore(); + + return ( +
+ +
+
+
+ + UnVibe +
+
+

mock workspace

+
+
+ +
+

{user?.name ?? "Guest"}

+

{user?.email ?? "mock session"}

+
+ +
+
+
{children}
+ +
+
+ ); +} diff --git a/apps/web/src/components/app/loading-panel.tsx b/apps/web/src/components/app/loading-panel.tsx new file mode 100644 index 0000000..be2187a --- /dev/null +++ b/apps/web/src/components/app/loading-panel.tsx @@ -0,0 +1,12 @@ +import { Card, CardContent } from "@/components/ui/card"; + +export function LoadingPanel({ label = "Loading mock data" }: { label?: string }) { + return ( + + +
+

{label}

+ + + ); +} diff --git a/apps/web/src/components/app/page-header.tsx b/apps/web/src/components/app/page-header.tsx new file mode 100644 index 0000000..86bb1be --- /dev/null +++ b/apps/web/src/components/app/page-header.tsx @@ -0,0 +1,14 @@ +import { Badge } from "@/components/ui/badge"; + +export function PageHeader({ eyebrow, title, description, action }: { eyebrow?: string; title: string; description?: string; action?: React.ReactNode }) { + return ( +
+
+ {eyebrow ? {eyebrow} : null} +

{title}

+ {description ?

{description}

: null} +
+ {action} +
+ ); +} diff --git a/apps/web/src/components/app/theme-controller.tsx b/apps/web/src/components/app/theme-controller.tsx new file mode 100644 index 0000000..f03ea16 --- /dev/null +++ b/apps/web/src/components/app/theme-controller.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { Moon, Sun } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useUIStore } from "@/stores/ui-store"; + +export function ThemeController() { + const { darkMode, toggleDarkMode } = useUIStore(); + + return ( + + ); +} diff --git a/apps/web/src/components/app/theme-provider.tsx b/apps/web/src/components/app/theme-provider.tsx new file mode 100644 index 0000000..5c622c4 --- /dev/null +++ b/apps/web/src/components/app/theme-provider.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { useEffect } from "react"; +import { useUIStore } from "@/stores/ui-store"; + +export function ThemeProvider({ children }: { children: React.ReactNode }) { + const darkMode = useUIStore((state) => state.darkMode); + + useEffect(() => { + document.documentElement.classList.toggle("dark", darkMode); + }, [darkMode]); + + return ( +
+
+
{children}
+
+ ); +} diff --git a/apps/web/src/components/features/annotation-editor.tsx b/apps/web/src/components/features/annotation-editor.tsx new file mode 100644 index 0000000..a91f9d2 --- /dev/null +++ b/apps/web/src/components/features/annotation-editor.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { useState } from "react"; +import type { Annotation } from "@/lib/mock-data/types"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; + +export function AnnotationEditor({ annotations: initial }: { annotations: Annotation[] }) { + const [annotations, setAnnotations] = useState(initial); + const [note, setNote] = useState(""); + + return ( +
+
+