Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions frontend/src/app/lessons/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
'use client';

import Link from 'next/link';
import { ArrowLeft } from 'lucide-react';
import { motion } from 'framer-motion';
import LessonWorkspace from '@/components/lesson/LessonWorkspace';

/**
* /lessons — demonstrates the interactive lesson workspace: lesson content on
* the left, a live Monaco code editor side-panel on the right.
*
* This page wires a sample Soroban lesson into {@link LessonWorkspace}; real
* curriculum data can be passed in the same shape from the course pages.
*/

const SAMPLE_STARTER = `#![no_std]
use soroban_sdk::{contract, contractimpl, Env, Symbol, symbol_short};

#[contract]
pub struct Greeter;

#[contractimpl]
impl Greeter {
// TODO: return a greeting Symbol from this function.
pub fn greet(env: Env) -> Symbol {
symbol_short!("hello")
}
}`;

export default function LessonsPage() {
return (
<div className="bg-background text-foreground relative min-h-screen overflow-hidden pb-20 transition-colors duration-200">
{/* Background glows */}
<div className="pointer-events-none absolute top-0 right-0 h-[800px] w-[800px] rounded-full bg-red-600/5 blur-[150px]"></div>
<div className="pointer-events-none absolute bottom-0 left-0 h-[600px] w-[600px] rounded-full bg-red-600/5 blur-[120px]"></div>

{/* Navigation */}
<nav className="bg-bg-secondary/80 border-border-theme relative sticky top-0 z-20 border-b backdrop-blur-md">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="flex h-20 items-center gap-4">
<Link
href="/dashboard"
className="text-text-secondary hover:text-foreground flex items-center gap-2 transition-colors"
>
<ArrowLeft className="h-5 w-5" />
<span className="text-sm font-bold tracking-widest uppercase">Back</span>
</Link>
<span className="text-foreground flex items-center gap-2 text-2xl font-black tracking-tighter uppercase">
<span className="h-2 w-2 animate-pulse rounded-full bg-red-500"></span>
Interactive <span className="text-red-600">Lesson</span>
</span>
</div>
</div>
</nav>

{/* Main Content */}
<main className="relative z-10 mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="mb-12 border-l-4 border-red-600 py-2 pl-6"
>
<h1 className="text-foreground mb-3 text-4xl font-black tracking-tight uppercase md:text-5xl">
Write Your First <span className="text-red-600">Contract</span>
</h1>
<p className="text-text-secondary text-lg font-light tracking-wide">
Follow the lesson and experiment in the live editor beside it.
</p>
</motion.div>

<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<LessonWorkspace title="Your First Soroban Contract" starterCode={SAMPLE_STARTER}>
<p>
Soroban contracts are written in Rust. Every contract is a struct
annotated with <code className="text-foreground">#[contract]</code>, and its callable
methods live in an <code className="text-foreground">impl</code> block marked
<code className="text-foreground"> #[contractimpl]</code>.
</p>
<p>
In the editor on the right, the <code className="text-foreground">greet</code> function
already returns a short symbol. Try editing the returned value, or add a new method
that takes a name and returns a personalised greeting.
</p>
<p>
The editor provides Rust syntax highlighting and basic autocompletion. Use the
<span className="text-foreground"> Reset</span> button in the editor header to restore
the starter code at any time.
</p>
</LessonWorkspace>
</motion.div>
</main>
</div>
);
}
189 changes: 189 additions & 0 deletions frontend/src/components/lesson/LessonCodeEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
'use client';

import dynamic from 'next/dynamic';
import type { OnMount } from '@monaco-editor/react';
import React, { useCallback, useEffect, useState } from 'react';
import { RotateCcw } from 'lucide-react';
import { extendRustLanguage } from '@/lib/editor/SorobanLanguage';
import { registerSorobanCompletion } from '@/lib/editor/SorobanCompletion';

/**
* LessonCodeEditor — a self-contained Monaco editor for the lesson side-panel.
*
* Design notes:
* - **Bundle size**: Monaco is heavy, so `@monaco-editor/react` is pulled in via
* `next/dynamic` with `ssr: false`. It is never part of the server bundle and
* only downloads on the client when a lesson with an editor is opened.
* - **Rust support**: syntax highlighting and basic autocomplete are wired up by
* reusing the shared `extendRustLanguage` / `registerSorobanCompletion`
* helpers, so the lesson editor stays consistent with the playground without
* duplicating the language definition.
* - **Decoupled**: unlike the playground editor, this component has no
* collaboration/compile coupling. It owns a single string of code, reports
* changes via `onChange`, and can reset back to the lesson's starter code.
* - **Resilient**: if Monaco fails to load, an accessible <textarea> fallback
* keeps the lesson usable.
*/
export interface LessonCodeEditorProps {
/** Starter code shown when the editor first mounts and after a reset. */
initialCode: string;
/** Monaco language id. Defaults to Rust, the curriculum's primary language. */
language?: string;
/** Filename shown in the editor header (purely cosmetic). */
filename?: string;
/** Called with the full editor contents whenever the code changes. */
onChange?: (value: string) => void;
}

const Editor = dynamic(() => import('@monaco-editor/react'), {
ssr: false,
loading: () => (
<div
role="status"
aria-live="polite"
className="flex h-full min-h-[300px] w-full items-center justify-center bg-zinc-950 text-zinc-500"
>
<div className="flex flex-col items-center gap-4">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-red-500 border-t-transparent" />
<p className="text-xs tracking-widest uppercase">Loading editor…</p>
</div>
</div>
),
});

/** Catches Monaco runtime failures and swaps in the plain-text fallback. */
class EditorErrorBoundary extends React.Component<
{ children: React.ReactNode; onError: () => void },
{ hasError: boolean }
> {
constructor(props: { children: React.ReactNode; onError: () => void }) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch() {
this.props.onError();
}
render() {
return this.state.hasError ? null : this.props.children;
}
}

function FallbackTextarea({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) {
return (
<div className="flex h-full w-full flex-col bg-zinc-950">
<div className="border-b border-red-500/20 bg-red-500/10 px-4 py-2">
<span className="text-xs font-bold tracking-wider text-red-400 uppercase">
Editor unavailable — using plain text
</span>
</div>
<textarea
aria-label="Lesson code editor (fallback)"
className="h-full w-full resize-none border-0 bg-zinc-950 p-4 font-mono text-sm text-zinc-300 outline-none"
value={value}
onChange={(e) => onChange(e.target.value)}
spellCheck={false}
/>
</div>
);
}

export function LessonCodeEditor({
initialCode,
language = 'rust',
filename = 'lib.rs',
onChange,
}: LessonCodeEditorProps) {
const [code, setCode] = useState(initialCode);
const [monacoError, setMonacoError] = useState(false);

// Keep local state in sync if the lesson (and therefore its starter) changes.
useEffect(() => {
setCode(initialCode);
}, [initialCode]);

const handleChange = useCallback(
(value: string | undefined) => {
const next = value ?? '';
setCode(next);
onChange?.(next);
},
[onChange]
);

const handleReset = useCallback(() => {
setCode(initialCode);
onChange?.(initialCode);
}, [initialCode, onChange]);

const handleMount: OnMount = useCallback((_editor, monaco) => {
// Register Rust highlighting + basic autocomplete for the lesson editor.
extendRustLanguage(monaco);
registerSorobanCompletion(monaco);
}, []);

const isDirty = code !== initialCode;

return (
<section
role="region"
aria-label="Lesson code editor"
className="flex h-full min-h-[300px] flex-col overflow-hidden rounded-2xl border border-white/10 bg-[#09090b]"
>
<header className="flex items-center justify-between border-b border-white/5 bg-black/40 px-4 py-2">
<span className="font-mono text-[11px] tracking-widest text-gray-400 uppercase">
{filename}
</span>
<button
type="button"
onClick={handleReset}
disabled={!isDirty}
aria-label="Reset code to the lesson starter"
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-[10px] font-bold tracking-widest text-gray-400 uppercase transition-colors hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
>
<RotateCcw className="h-3 w-3" aria-hidden="true" />
Reset
</button>
</header>

<div className="relative flex-grow">
{monacoError ? (
<FallbackTextarea value={code} onChange={handleChange} />
) : (
<EditorErrorBoundary onError={() => setMonacoError(true)}>
<Editor
height="100%"
defaultLanguage={language}
language={language}
value={code}
onChange={handleChange}
onMount={handleMount}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 14,
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
tabSize: 4,
insertSpaces: true,
automaticLayout: true,
scrollBeyondLastLine: false,
wordWrap: 'on',
padding: { top: 16 },
}}
/>
</EditorErrorBoundary>
)}
</div>
</section>
);
}

export default LessonCodeEditor;
65 changes: 65 additions & 0 deletions frontend/src/components/lesson/LessonWorkspace.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
'use client';

import { useState } from 'react';
import LessonCodeEditor from './LessonCodeEditor';

/**
* LessonWorkspace — pairs lesson content with the interactive code editor.
*
* Layout: on large screens the lesson content and the Monaco editor sit
* side-by-side (two columns); on small screens they stack so the content stays
* readable. The editor column is sticky on desktop so it remains visible while
* the student scrolls through longer lessons.
*
* The component intentionally accepts the lesson body as `children`, so callers
* can render rich content (headings, prose, code samples) however they like
* while this component owns only the split layout and editor wiring.
*/
export interface LessonWorkspaceProps {
/** Lesson title shown above the content column. */
title: string;
/** Starter code loaded into the editor. */
starterCode: string;
/** Editor language id (defaults to Rust). */
language?: string;
/** Lesson body content. */
children: React.ReactNode;
}

export function LessonWorkspace({
title,
starterCode,
language = 'rust',
children,
}: LessonWorkspaceProps) {
// Lift editor state so the lesson could later react to the student's code
// (e.g. run/validate). For now it powers the "unsaved changes" hint.
const [code, setCode] = useState(starterCode);
const hasEdits = code !== starterCode;

return (
<div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
{/* Lesson content */}
<article className="bg-bg-secondary border-border-theme rounded-2xl border p-6">
<h2 className="text-foreground mb-4 text-2xl font-black tracking-tight uppercase">
{title}
</h2>
<div className="text-text-secondary space-y-4 text-sm leading-relaxed">{children}</div>
</article>

{/* Editor side-panel */}
<div className="flex flex-col gap-2 lg:sticky lg:top-24 lg:self-start">
<LessonCodeEditor
initialCode={starterCode}
language={language}
onChange={setCode}
/>
<p className="text-text-secondary text-right text-[11px] tracking-widest uppercase" aria-live="polite">
{hasEdits ? 'Unsaved changes' : 'Starter code'}
</p>
</div>
</div>
);
}

export default LessonWorkspace;
Loading
Loading