# Project export: Studium

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: CruzHacks 2026
- Tagline: Convert transcripts into interactive study materials
- Devpost: https://devpost.com/software/studium-or72y6
- GitHub: https://github.com/NickB-30/CruzHacks2026
- Video: https://www.youtube.com/embed/OLRlTfKZ1YM?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 2 GitHub contributor(s) — jonnysnz (7 commits), NickB-30 (4 commits)

## Devpost submission (written by the team)

### Inspiration

We noticed that very few classmates were rewatching lecture recordings, even when they struggled with the material. The barrier wasn't a lack of motivation—it was time. Sitting through hours of lecture footage to review key concepts felt overwhelming. We asked ourselves: what if we could transform dense lecture transcripts into engaging, digestible study videos that students would actually want to watch? That's how Studium was born.

### What it does

Studium converts lecture transcript PDFs into personalized study videos and emails them directly to students. Users simply upload their lecture transcript PDF and enter their email address through our web interface. Within minutes, they receive a custom-generated video that explains the material in a clear, concise way—turning hours of lecture content into focused study resources.

### How we built it

We built Studium with a full-stack approach that seamlessly connects multiple technologies: Frontend: Framer for a modern, intuitive UI where users upload transcripts and enter their email Backend: Flask API to handle file uploads, validate data, and communicate with our workflow engine Workflow Automation: n8n to orchestrate the entire pipeline—from receiving the PDF to coordinating AI services AI Processing: OpenAI to analyze transcripts and generate engaging video scripts Video Generation: OpenNote API to transform scripts into professional study videos Delivery: Automated email system to send completed videos directly to students

### Challenges we ran into

Our biggest challenge was fully automating the AI pipeline with n8n. As first-time users of the platform, we had to learn how to properly configure webhooks, manage data flow between different APIs, and handle error cases—all while racing against the hackathon clock. Debugging the communication between Flask, n8n, OpenAI, and OpenNote required patience and creative problem-solving, but we eventually got all the pieces working together seamlessly.

### Accomplishments we're proud of

We're incredibly proud of building a complete, functional AI pipeline from scratch in just one weekend. Watching a PDF transcript transform into an actual study video—automatically—felt like magic. We successfully integrated four different technologies (Framer, Flask, n8n, and multiple AI APIs) into a cohesive product that solves a real problem students face every day.

### What we learned

This project taught us invaluable lessons about building AI-powered applications and connecting disparate systems. We learned how to design and implement complex workflows with n8n, how to properly structure API communications between services, and how to think about user experience when dealing with asynchronous processes. Most importantly, we learned that ambitious ideas are achievable when you break them down into manageable components and tackle them systematically.

### What's next

Our vision is to integrate Studium directly into the Yuja platform (our university's lecture recording system). This would allow students to generate study materials with a single click—right from the lecture page they're already viewing. We also plan to add features like customizable video length, topic-specific focus areas, and support for multiple study formats (flashcards, quiz questions, summary notes) to give students even more ways to learn effectively.

## README (from the GitHub repository)

# Studium 📚

> Transform lecture transcripts into engaging study videos, delivered straight to your inbox.

[![CruzHacks 2026](https://img.shields.io/badge/CruzHacks-2026-blue)](https://cruzhacks.com)
[![Python](https://img.shields.io/badge/Python-3.8+-green.svg)](https://www.python.org/)
[![Flask](https://img.shields.io/badge/Flask-3.0.0-lightgrey.svg)](https://flask.palletsprojects.com/)

## 🎯 Inspiration

We noticed that very few classmates were rewatching lecture recordings, even when they struggled with the material. The barrier wasn't a lack of motivation—it was time. Sitting through hours of lecture footage to review key concepts felt overwhelming. We created Studium to transform dense lecture transcripts into engaging, digestible study videos that students would actually want to watch.

## ✨ What it does

Studium converts lecture transcript PDFs into personalized study videos and emails them directly to students. Users simply:
1. Upload their lecture transcript PDF
2. Enter their email address
3. Receive a custom-generated study video within minutes

The AI analyzes the transcript, generates an engaging script, creates a video explanation, and delivers it via email—turning hours of lecture content into focused study resources.

## 🏗️ Architecture

```
Frontend (Framer) → Flask API → n8n Workflow → OpenAI → OpenNote API → Email
```

### Tech Stack
- **Frontend**: Framer (React-based UI)
- **Backend**: Flask (Python)
- **Workflow**: n8n (automation platform)
- **AI Processing**: OpenAI API (script generation)
- **Video Generation**: OpenNote API
- **Deployment**: ngrok (development), ready for production deployment

## 🚀 Getting Started

### Prerequisites
- Python 3.8+
- ngrok (for local development)
- n8n account
- OpenAI API key
- OpenNote API key

### Installation

1. **Clone the repository**
```bash
git clone https://github.com/NickB-30/CruzHacks2026.git
cd CruzHacks2026
```

2. **Install Python dependencies**
```bash
pip install -r requirements.txt
```

3. **Set up environment variables**
```bash
export N8N_TRANSCRIPT_WEBHOOK="your-n8n-webhook-url"
export TEST_MODE="false"  # Set to "true" for testing without n8n
```

4. **Run the Flask backend**
```bash
python app.py
```

5. **Expose Flask with ngrok** (in a new terminal)
```bash
ngrok http 5000
```

6. **Update Framer frontend**
- Copy the ngrok URL
- Update the fetch URL in your Framer component to use the ngrok URL

### n8n Workflow Setup

1. Create a new workflow in n8n
2. Add a Webhook node (POST method)
3. Configure the following nodes:
   - PDF text extraction
   - OpenAI node (script generation)
   - OpenNote API call (video generation)
   - Email node (delivery)
4. Activate the workflow
5. Copy the production webhook URL to your Flask environment variables

## 📁 Project Structure

```
CruzHacks2026/
├── app.py                 # Flask backend API
├── requirements.txt       # Python dependencies
├── uploads/              # Temporary storage for uploaded PDFs
├── README.md             # This file
└── framer/               # Framer frontend code (separate)
```

## 🔑 API Endpoints

### `POST /upload/transcript`
Upload a transcript PDF and trigger video generation.

**Request:**
- `file`: PDF file (multipart/form-data)
- `email`: User's email address (string)

**Response:**
```json
{
  "message": "Transcript processed successfully",
  "filename": "lecture.pdf",
  "status": "success",
  "n8n_response": "..."
}
```

### `GET /health`
Health check endpoint.

**Response:**
```json
{
  "status": "healthy"
}
```

## 🛠️ Configuration

### Environment Variables
- `N8N_TRANSCRIPT_WEBHOOK`: Your n8n webhook URL
- `TEST_MODE`: Set to `"true"` to skip n8n and test file uploads only

### File Constraints
- **Allowed formats**: PDF only
- **Max file size**: 50MB
- **Email validation**: Standard email format required

## 🧪 Testing

### Test Mode
Enable test mode to verify file uploads without calling n8n:
```bash
export TEST_MODE="true"
python app.py
```

### Manual API Testing
```bash
curl -X POST -F "file=@transcript.pdf" -F "email=test@example.com" http://localhost:5000/upload/transcript
```

## 🎨 Frontend

The frontend is built with Framer and features:
- Modern, gradient-based UI
- Drag-and-drop file upload
- Real-time status updates
- Responsive design
- Email validation

## 🚧 Challenges

Our biggest challenge was fully automating the AI pipeline with n8n. As first-time users, we had to learn webhook configuration, data flow management, and error handling—all within the hackathon timeframe. Successfully connecting Flask, n8n, OpenAI, and OpenNote APIs required creative problem-solving and persistent debugging.

## 🏆 Accomplishments

- Built a complete AI pipeline from scratch in one weekend
- Successfully integrated 4+ different technologies into a cohesive product
- Created an elegant, user-friendly interface
- Solved a real problem that students face daily

## 📚 What We Learned

- Designing and implementing complex workflows with n8n
- Structuring API communications between multiple services
- Handling asynchronous processes in user-facing applications
- The importance of breaking ambitious ideas into manageable components

## 🔮 What's Next

- **Yuja Integration**: One-click study videos directly from lecture pages
- **Multiple Formats**: Add flashcards, quizzes, and summary notes
- **Customization**: Allow users to choose video length and focus topics
- **Scale**: Expand to universities nationwide
- **Enhanced AI**: Improve script quality and add citations/sources

## 👥 Team

Built with ❤️ at CruzHacks 2026

## 📄 License

This project was created for CruzHacks 2026.

## 🙏 Acknowledgments

- CruzHacks 2026 organizers
- OpenAI for providing powerful language models
- OpenNote for video generation capabilities
- n8n community for workflow automation tools

---

**Note**: This is a hackathon prototype. API keys and production credentials should be properly secured before deployment.


## Detected evidence (automated analysis)

Indexed codebase: 6 recognized source files, 50 KB.
- Flask (technology) — detected in the code
- Python (language) — detected in the code
- TypeScript (language) — detected in the code

## Codebase structure (from repository index)

### Files (8 of 8)

```
.DS_Store
app.py
App/components/Examples.tsx
App/components/NavBar.tsx
App/HomePage.tsx
App/screens/UploadForm.tsx
README.md
requirements.txt
```

### Dependencies

- requirements.txt: flask@==3.0.0, flask-cors@==4.0.0, requests@==2.31.0, werkzeug@==3.0.1

### Recent commits (newest first)

- Update README.md
- Merge pull request #3 from NickB-30/my_branch
- Merge branch 'main' into my_branch
- updates
- added n8n webhook
- hi
- hi
- test2
- test
- studium
- studium
- studium
- studium
- Merge branch 'main' of github.com:NickB-30/CruzHacks2026
- Initial commit - Flask backend
- Initial commit

## Key source files (fetched from GitHub, selected and truncated for size)

### requirements.txt

```
flask==3.0.0
flask-cors==4.0.0
requests==2.31.0
werkzeug==3.0.1
```

### app.py

```python
from flask import Flask, request, jsonify
from flask_cors import CORS
import requests
import os
import re
from werkzeug.utils import secure_filename

app = Flask(__name__)
CORS(app)

# Configuration
UPLOAD_FOLDER = 'uploads'
ALLOWED_TRANSCRIPT_EXTENSIONS = None  # allow any extension
MAX_FILE_SIZE = 50 * 1024 * 1024  # 50MB

app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
app.config['MAX_CONTENT_LENGTH'] = MAX_FILE_SIZE

# n8n webhook URL - replace with your actual URL
N8N_TRANSCRIPT_WEBHOOK = os.getenv('N8N_TRANSCRIPT_WEBHOOK', 'https://your-n8n-instance.com/webhook/transcript')
TEST_MODE = os.getenv('TEST_MODE', 'true').lower() == 'true'  # Set to 'false' when n8n is ready

# Create upload folder if it doesn't exist
os.makedirs(UPLOAD_FOLDER, exist_ok=True)

def allowed_file(filename, allowed_extensions):
    """Return True if filename is allowed. If allowed_extensions is None, allow any file."""
    if allowed_extensions is None:
        return True
    return '.' in filename and filename.rsplit('.', 1)[1].lower() in allowed_extensions

def is_valid_email(email):
    """Validate email format"""
    pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
    return re.match(pattern, email) is not None

@app.route('/upload/transcript', methods=['POST'])
def upload_transcript():
    """GET transcript file + email from frontend, store locally, then POST to n8n"""
    # Accept both field sets: file/email or transcript_pdf/email_address
    file = request.files.get('file') or request.files.get('transcript_pdf')
    email = request.form.get('email') or request.form.get('email_address')

    if not file:
        return jsonify({'error': 'No file provided'}), 400

    if not email:
        return jsonify({'error': 'No email provided'}), 400
    
    if file.filename == '':
        return jsonify({'error': 'No file selected'}), 400
    
    if not is_valid_email(email):
        return jsonify({'error': 'Invalid email address format'}), 400
    
    if not allowed_file(file.filename, ALLOWED_TRANSCRIPT_EXTENSIONS):
        return jsonify({'error': 'Invalid file type'}), 400
    
    try:
        # Store locally
        filename = secure_filename(file.filename)
        filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
        file.save(filepath)
        
        # Print to terminal
        print(f"\n{'='*50}")
        print(f"📄 File uploaded: {filename}")
        print(f"📧 Email: {email}")
        print(f"{'='*50}\n")
        
        # TEST MODE: Skip n8n and return mock success
        if TEST_MODE:
            return jsonify({
                'message': 'Transcript processed successfully (TEST MODE)',
                'filename': filename,
                'email': email,
                'n8n_response': 'Test mode enabled - n8n not called. File saved successfully!',
                'status': 'success'
            }), 200
        
        # POST to n8n (only when TEST_MODE is false)
        with open(filepath, 'rb') as f:
            files = {'transcript_pdf': (filename, f, file.mimetype or 'application/octet-stream')}
            payload = {'email_address': email}
            response = requests.post(
                N8N_TRANSCRIPT_WEBHOOK,
                files=files,
                data=payload,
                timeout=120,
            )
        
        # Return n8n response to frontend
        return jsonify({
            'message': 'Transcript processed successfully',
            'filename': filename,
            'n8n_response': response.json() if response.headers.get('content-type') == 'application/json' else response.text,
            'status': 'success' if response.status_code == 200 else 'error'
        }), response.status_code
    
    except Exception as e:
        return jsonify({'error': str(e), 'status': 'error'}), 500

@app.route('/health', methods=['GET'])
def health_check():
    return jsonify({'status': 'healthy'}), 200

if __name__ == '__main__':
    app.run(host='0.0.0.0', debug=True, port=5050)
```

### App/HomePage.tsx

```typescript
import * as React from "react"
import { addPropertyControls, ControlType } from "framer"

/**
 * @framerSupportedLayoutWidth any
 * @framerSupportedLayoutHeight any
 */
export default function HomePage(props) {
    const {
        title = "Turn transcripts into real learning",
        subtitle = "Upload your lecture transcript. Get clean notes, quizzes, and flashcards instantly.",
        demoLink = "/demo",
    } = props


    const pageStyle = {
        minHeight: "100vh",
        width: "100%",
        paddingTop: "140px",
        display: "flex",
        justifyContent: "center",
        fontFamily:
            '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
        background:
            "radial-gradient(circle at top, rgba(102,126,234,0.12), transparent 60%)",
    }

    const containerStyle = {
        maxWidth: "1000px",
        width: "100%",
        padding: "0 24px",
        display: "flex",
        flexDirection: "column",
        gap: "96px",
    }

    const heroStyle = {
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        textAlign: "center",
        gap: "20px",
    }

    const badgeStyle = {
        padding: "8px 18px",
        borderRadius: "999px",
        background: "rgba(102, 126, 234, 0.12)",
        color: "#667eea",
        fontSize: "24px",
        fontWeight: 600,
    }

    const titleStyle = {
        fontSize: "56px",
        fontWeight: 800,
        lineHeight: 1.05,
        margin: 0,
        background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
        WebkitBackgroundClip: "text",
        WebkitTextFillColor: "transparent",
        backgroundClip: "text",
        letterSpacing: "-1px",
    }

    const subtitleStyle = {
        fontSize: "20px",
        maxWidth: "640px",
        lineHeight: 1.5,
        color: "#555",
        margin: 0,
    }

    const ctaRowStyle = {
        display: "flex",
        gap: "14px",
        marginTop: "12px",
        flexWrap: "wrap",
        justifyContent: "center",
    }

    const pillStyle = {
        padding: "14px 32px",
        borderRadius: "999px",
        background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
        color: "white",
        fontSize: "15px",
        fontWeight: 600,
        boxShadow: "0 6px 20px rgba(102, 126, 234, 0.35)",
        cursor: "pointer",
        transition: "all 0.2s ease",
        border: "none",
        outline: "none",
        whiteSpace: "nowrap",
    }

    const ghostPillStyle = {
        ...pillStyle,
        background: "rgba(102,126,234,0.12)",
        color: "#667eea",
        boxShadow: "none",
    }

    const hoverIn = (e) => {
        e.currentTarget.style.transform = "translateY(-2px)"
        e.currentTarget.style.boxShadow =
            "0 10px 28px rgba(102, 126, 234, 0.45)"
    }

    const hoverOut = (e) => {
        e.currentTarget.style.transform = "translateY(0)"
        e.currentTarget.style.boxShadow = "0 6px 20px rgba(102, 126, 234, 0.35)"
    }

    const resolveUrl = (link) => {
        if (!link) return "/demo"
        // If Framer provides a full URL, keep it
        if (/^https?:\/\//i.test(link)) return link
        // Otherwise, resolve relative paths against the current origin
        return new URL(link, window.location.origin).toString()
    }

    const handleDemoNav = (e) => {
        e.preventDefault()
        const url = resolveUrl(demoLink)

        // In the editor, components can be embedded; opening a new tab avoids editor-route 404s.
        // On the published site, navigate normally.
        const isEmbedded = (() => {
            try {
                return window.top !== window.self
            } catch {
                return true
            }
        })()

        if (isEmbedded) {
            window.open(url, "_blank", "noopener,noreferrer")
        } else {
            window.location.assign(url)
        }
    }

    const sectionStyle = {
        display: "flex",
        flexDirection: "column",
        gap: "36px",
    }

    const sectionTitleStyle = {
        fontSize: "36px",
        fontWeight: 700,
        margin: 0,
        letterSpacing: "-0.5px",
    }

    const gridStyle = {
        display: "grid",
        gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))",
        gap: "24px",
    }

    const cardStyle = {
        padding: "28px",
        borderRadius: "28px",
        background: "rgba(255,255,255,0.75)",
        backdropFilter: "blur(12px)",
        WebkitBackdropFilter: "blur(12px)",
        boxShadow: "0 12px 40px rgba(0,0,0,0.08)",
        border: "1px solid rgba(255,255,255,0.4)",
        display: "flex",
        flexDirection: "column",
        gap: "12px",
    }

    const cardTitleStyle = {
        fontSize: "18px",
        fontWeight: 700,
        margin: 0,
    }

    const cardTextStyle = {
        fontSize: "15px",
        lineHeight: 1.55,
        color: "#555",
        margin: 0,
    }

    const stackBadgeStyle = {
        padding: "10px 16px",
        borderRadius: "999px",
        background: "rgba(102,126,234,0.12)",
        color: "#667eea",
        fontSize: "13px",
        fontWeight: 600,
        whiteSpace: "nowrap",
    }

    return (
        <div style={pageStyle}>
            <div style={containerStyle}>
                {/* Hero */}
                <section style={heroStyle}>
                    <div style={badgeStyle}>📚 Studium</div>
                    <h1 style={titleStyle}>{title}</h1>
                    <p style={subtitleStyle}>
                        Upload your lecture transcript PDF and instantly get
                        structured notes, quizzes, and flashcards — so studying
                        feels effortless instead of overwhelming.
                    </p>

                    <div style={ctaRowStyle}>
                        <a
                            href={demoLink}
                            onClick={handleDemoNav}
                            
[truncated — 5065 more characters]
```

### App/components/Examples.tsx

```typescript
import { forwardRef, type ComponentType } from "react"
import { createStore } from "https://framer.com/m/framer/store.js@^1.0.0"
import { randomColor } from "https://framer.com/m/framer/utils.js@^0.9.0"

// Learn more: https://www.framer.com/developers/overrides/

const useStore = createStore({
    background: "#0099FF",
})

export function withRotate(Component): ComponentType {
    return forwardRef((props, ref) => {
        return (
            <Component
                ref={ref}
                {...props}
                animate={{ rotate: 90 }}
                transition={{ duration: 2 }}
            />
        )
    })
}

export function withHover(Component): ComponentType {
    return forwardRef((props, ref) => {
        return <Component ref={ref} {...props} whileHover={{ scale: 1.05 }} />
    })
}

export function withRandomColor(Component): ComponentType {
    return forwardRef((props, ref) => {
        const [store, setStore] = useStore()

        return (
            <Component
                ref={ref}
                {...props}
                animate={{
                    background: store.background,
                }}
                onClick={() => {
                    setStore({ background: randomColor() })
                }}
            />
        )
    })
}

```

### App/components/NavBar.tsx

```typescript
import * as React from "react"
import { addPropertyControls, ControlType } from "framer"

/**
 * @framerSupportedLayoutWidth any
 * @framerSupportedLayoutHeight fixed
 */
export default function Navbar(props) {
    const {
        homePage = "/",
        demoPage = "/demo",
        githubLink = "https://github.com/NickB-30/CruzHacks2026",
    } = props

    const navStyle = {
        width: "100%",
        height: "100px",
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
        padding: "0 20px",
        position: "fixed",
        top: 0,
        left: 0,
        zIndex: 1000,
        fontFamily:
            '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
    }

    const containerStyle = {
        maxWidth: "1000px",
        width: "100%",
        height: "72px",
        background: "rgba(255, 255, 255, 0.9)",
        backdropFilter: "blur(12px)",
        WebkitBackdropFilter: "blur(12px)",
        borderRadius: "36px",
        display: "flex",
        alignItems: "center",
        justifyContent: "space-between",
        padding: "0 20px 0 28px",
        boxShadow: "0 10px 40px rgba(0, 0, 0, 0.08)",
        border: "1px solid rgba(255, 255, 255, 0.4)",
    }

    const logoStyle = {
        display: "flex",
        alignItems: "center",
        gap: "10px",
        textDecoration: "none",
        cursor: "pointer",
    }

    const brandNameStyle = {
        fontSize: "22px",
        fontWeight: "700",
        background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
        WebkitBackgroundClip: "text",
        WebkitTextFillColor: "transparent",
        backgroundClip: "text",
        margin: 0,
        letterSpacing: "-0.5px",
    }

    const rightSectionStyle = {
        display: "flex",
        alignItems: "center",
        gap: "12px",
    }

    const pillStyle = {
        padding: "12px 28px",
        borderRadius: "24px",
        background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
        color: "white",
        fontSize: "15px",
        fontWeight: "600",
        boxShadow: "0 4px 15px rgba(102, 126, 234, 0.3)",
        transition: "all 0.2s ease",
        cursor: "pointer",
        whiteSpace: "nowrap",
    }

    const hoverIn = (e) => {
        e.currentTarget.style.transform = "translateY(-2px)"
        e.currentTarget.style.boxShadow = "0 6px 20px rgba(102, 126, 234, 0.4)"
    }

    const hoverOut = (e) => {
        e.currentTarget.style.transform = "translateY(0)"
        e.currentTarget.style.boxShadow = "0 4px 15px rgba(102, 126, 234, 0.3)"
    }

    const resolveUrl = (link) => {
        if (!link) return "/"
        // If a full URL is provided, keep it
        if (/^https?:\/\//i.test(link)) return link
        // Otherwise resolve relative paths against this origin
        return new URL(link, window.location.origin).toString()
    }

    const handleNav = (e, link) => {
        e.preventDefault()
        const url = resolveUrl(link)

        // In the Framer editor/canvas the component can be embedded; opening a new tab avoids route 404s.
        const isEmbedded = (() => {
            try {
                return window.top !== window.self
            } catch {
                return true
            }
        })()

        if (isEmbedded) {
            window.open(url, "_blank", "noopener,noreferrer")
        } else {
            window.location.assign(url)
        }
    }

    return (
        <nav style={navStyle}>
            <div style={containerStyle}>
                {/* Logo */}
                <a
                    href={homePage}
                    onClick={(e) => handleNav(e, homePage)}
                    style={{ textDecoration: "none", display: "inline-block" }}
                >
                    <div style={logoStyle}>
                        <span style={{ fontSize: "28px" }}>📚</span>
                        <h1 style={brandNameStyle}>Studium</h1>
                    </div>
                </a>

                {/* Buttons */}
                <div style={rightSectionStyle}>
                    <a
                        href={homePage}
                        onClick={(e) => handleNav(e, homePage)}
                        style={{ textDecoration: "none", display: "inline-block" }}
                    >
                        <div
                            style={pillStyle}
                            onMouseEnter={hoverIn}
                            onMouseLeave={hoverOut}
                        >
                            Home
                        </div>
                    </a>

                    <a
                        href={demoPage}
                        onClick={(e) => handleNav(e, demoPage)}
                        style={{ textDecoration: "none", display: "inline-block" }}
                    >
                        <div
                            style={pillStyle}
                            onMouseEnter={hoverIn}
                            onMouseLeave={hoverOut}
                        >
                            Demo
                        </div>
                    </a>

                    <a
                        href={githubLink}
                        target="_blank"
                        rel="noopener noreferrer"
                        style={{ textDecoration: "none" }}
                    >
                        <div
                            style={pillStyle}
                            onMouseEnter={hoverIn}
                            onMouseLeave={hoverOut}
                        >
                            GitHub
                        </div>
                    </a>
                </div>
            </div>
        </nav>
    )
}

addPropertyControls(Navbar, {
    homePage: { type: ControlType.Link, title: "Home Page" },
    demoPage: { type: ControlType.Link, title: "Demo Page" },
    githubLink: {
        type: ControlType.String,
        title: "GitHub URL",
        defaultV
[truncated — 61 more characters]
```

### App/screens/UploadForm.tsx

```typescript
import React from "react"

export default function UploadPage() {
    const [file, setFile] = React.useState(null)
    const [email, setEmail] = React.useState("")
    const [status, setStatus] = React.useState("")
    const [isLoading, setIsLoading] = React.useState(false)
    const [isDragging, setIsDragging] = React.useState(false)

    const API_BASE = "https://subvitalised-pajamaed-barabara.ngrok-free.dev"
    const API_URL = `${API_BASE}/upload/transcript`

    const handleUpload = async () => {
        if (!file) {
            setStatus("⚠️ Please select a PDF file")
            return
        }

        if (!email) {
            setStatus("⚠️ Please enter your email")
            return
        }

        setIsLoading(true)
        setStatus("⏳ Processing your transcript...")

        const formData = new FormData()
        // Prefer Flask's expected keys
        formData.append("file", file)
        formData.append("email", email)
        // Backward compatibility for n8n / older backend expectations (harmless extra fields)
        formData.append("transcript_pdf", file)
        formData.append("email_address", email)

        try {
            const response = await fetch(API_URL, {
                method: "POST",
                body: formData,
            })

            // Be resilient: Flask/404 pages might not return JSON.
            const text = await response.text()
            let data: any = {}
            try {
                data = JSON.parse(text)
            } catch {
                data = { error: text }
            }

            if (!response.ok) {
                setStatus(
                    `❌ Server error ${response.status}: ` +
                        (data.error || data.message || "Request failed")
                )
                return
            }

            if (data.status === "success") {
                setStatus(
                    "✅ Success! Your study materials will be emailed shortly."
                )
                setFile(null)
                setEmail("")
            } else {
                setStatus("❌ Error: " + (data.error || "Upload failed"))
            }
        } catch (error) {
            setStatus(
                "❌ Network error. Make sure Flask + ngrok are running and the API_URL is correct."
            )
        } finally {
            setIsLoading(false)
        }
    }

    const handleDrop = (e) => {
        e.preventDefault()
        setIsDragging(false)
        const droppedFile = e.dataTransfer.files[0]
        if (droppedFile && droppedFile.type === "application/pdf") {
            setFile(droppedFile)
        } else {
            setStatus("⚠️ Please drop a PDF file")
        }
    }

    const handleDragOver = (e) => {
        e.preventDefault()
        setIsDragging(true)
    }

    const handleDragLeave = () => {
        setIsDragging(false)
    }

    const pillHoverIn = (e) => {
        e.currentTarget.style.transform = "translateY(-2px)"
        e.currentTarget.style.boxShadow = "0 6px 20px rgba(102, 126, 234, 0.4)"
    }

    const pillHoverOut = (e) => {
        e.currentTarget.style.transform = "translateY(0)"
        e.currentTarget.style.boxShadow = "0 4px 15px rgba(102, 126, 234, 0.3)"
    }

    return (
        <div
            style={{
                width: "100%",
                minHeight: "100vh",
                background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
                fontFamily:
                    '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
            }}
        >
            {/* Navbar */}
            <nav
                style={{
                    width: "100%",
                    height: "100px",
                    display: "flex",
                    alignItems: "center",
                    justifyContent: "center",
                    padding: "0 20px",
                    position: "fixed",
                    top: 0,
                    left: 0,
                    zIndex: 1000,
                }}
            >
                <div
                    style={{
                        maxWidth: "1000px",
                        width: "100%",
                        height: "72px",
                        background: "rgba(255, 255, 255, 0.9)",
                        backdropFilter: "blur(12px)",
                        WebkitBackdropFilter: "blur(12px)",
                        borderRadius: "36px",
                        display: "flex",
                        alignItems: "center",
                        justifyContent: "space-between",
                        padding: "0 20px 0 28px",
                        boxShadow: "0 10px 40px rgba(0, 0, 0, 0.08)",
                        border: "1px solid rgba(255, 255, 255, 0.4)",
                    }}
                >
                    {/* Logo */}
                    <div
                        style={{
                            display: "flex",
                            alignItems: "center",
                            gap: "10px",
                            cursor: "pointer",
                        }}
                    >
                        <span style={{ fontSize: "28px" }}>📚</span>
                        <h1
                            style={{
                                fontSize: "22px",
                                fontWeight: "700",
                                background:
                                    "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
                                WebkitBackgroundClip: "text",
                                WebkitTextFillColor: "transparent",
                                backgroundClip: "text",
                                margin: 0,
                                letterSpacing: "-0.5px",
                            }}
                        >
                            Studium
                        </h1>
                    </div>

                    {/* Bu
[truncated — 16798 more characters]
```