# Project export: SlugSyllabus

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: Use SlugSyllabus to see what classes are actually like!
- Devpost: https://devpost.com/software/slugsyllabus
- GitHub: https://github.com/nikhilJain17/SlugSyllabus
- Demo: https://youtu.be/8zCWNUJher8
- Video: https://www.youtube.com/embed/8zCWNUJher8?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 1 GitHub contributor(s) — Nikhil Jain (3 commits)

## Devpost submission (written by the team)

### Inspiration

At UCSC, you don’t really know what a class is like until you’re already enrolled and see the syllabus. By then, it’s often too late to switch. The course catalog doesn’t tell you workload, grading style, or how strict the prerequisites really are. SlugSyllabus is a way to make that information visible before committing to a class.

### What it does

SlugSyllabus lets you browse real UCSC course syllabi and automatically pulls out the parts students actually care about: • workload and pacing • grading breakdown • prerequisite expectations • a quick TLDR of what the course emphasizes You can also compare two courses side by side to help decide which one is a better fit for a given quarter.

### How we built it

We built a small web app that treats the syllabus as the source of truth: • PDFs are uploaded and parsed • Gemini extracts structured insights from the syllabus text • results are cached so the app stays fast • the UI stays intentionally simple Demo Watch the demo video! https://youtu.be/8zCWNUJher8

## README (from the GitHub repository)

# SlugSyllabus (folder-only demo)

A pure-demo web app that stores uploaded syllabus PDFs in a folder and keeps metadata in a single JSON file (`index.json`).
Insights are generated on-demand via prompt keys (LLM stubbed) and optionally cached as text files in `cache/`.

## Prereqs
- Python 3.10+

# from project root
rm -rf .venv

# create fresh venv with a modern Python
python3.11 -m venv .venv
source .venv/bin/activate

# upgrade tooling
python -m pip install --upgrade pip setuptools wheel

# install deps explicitly (no guessing)
python -m pip install \
  fastapi \
  "uvicorn[standard]" \
  jinja2 \
  python-multipart \
  pypdf \
  markdown \
  google-generativeai

# sanity checks (ALL must succeed)
python -c "import fastapi; print('fastapi ok')"
python -c "import uvicorn; print('uvicorn ok')"
python -c "import google.generativeai as genai; print('gemini ok')"

# set your API key
export GEMINI_API_KEY="YOUR_KEY_HERE"

# RUN THE APP (IMPORTANT)
python -m uvicorn app.main:app --reload
Open:
- http://127.0.0.1:8000/

## How it works
- PDFs are stored in `uploads/`
- Metadata lives in `index.json` (auto-created)
- Insights tabs call `/insight/{slug}/{prompt_key}` which:
  - checks `cache/{slug}__{prompt_key}.txt`
  - if missing, calls the LLM stub and writes the cache file

## Where to plug in a real LLM
Edit `app/llm.py`:
- Replace `run_llm(prompt_key, syllabus_text)` with your provider call.
- For a demo, you can skip PDF->text and just send a short extracted snippet.

## Suggested demo script
1) Upload 2 PDFs
2) Open a syllabus detail page
3) Click TLDR / WORKLOAD / GRADING / PREREQS tabs
4) Refresh the page and click again to show caching is instant

## Gemini setup
Set your key:

```bash
export GEMINI_API_KEY="YOUR_KEY"
```

Install deps:

```bash
pip install -r requirements.txt
```


## Detected evidence (automated analysis)

Indexed codebase: 8 recognized source files, 32 KB.
- CSS (language) — detected in the code
- FastAPI (technology) — detected in the code
- HTML (language) — detected in the code
- Python (language) — detected in the code
- Google Gemini (technology) — claimed on Devpost, not found in the code

## Codebase structure (from repository index)

### Files (22 of 22)

```
app/cache/cse-237-scott-brandt-winter-2026__grading.txt
app/cache/cse-237-scott-brandt-winter-2026__prereqs.txt
app/cache/cse-237-scott-brandt-winter-2026__tldr.txt
app/cache/cse-237-scott-brandt-winter-2026__workload.txt
app/cache/cse-250-computer-networks-0__grading.txt
app/cache/cse-250-computer-networks-0__prereqs.txt
app/cache/cse-250-computer-networks-0__tldr.txt
app/cache/cse-250-computer-networks-0__workload.txt
app/llm.py
app/main.py
app/static/app.css
app/templates/base.html
app/templates/index.html
app/templates/syllabus.html
app/templates/upload.html
cache/cse-250a-chen-qian-winter-2026__grading.txt
cache/cse-250a-chen-qian-winter-2026__prereqs.txt
cache/cse-250a-chen-qian-winter-2026__tldr.txt
cache/cse-250a-chen-qian-winter-2026__workload.txt
index.json
README.md
requirements.txt
```

### Dependencies

- requirements.txt: fastapi, google-generativeai, jinja2, markdown, pypdf, python-multipart, uvicorn[standard]

### Recent commits (newest first)

- Add compare feature
- Fix more bugs and clean up
- Fix bugs in upload
- Initial commit
- Initial commit

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

### requirements.txt

```
fastapi
uvicorn[standard]
jinja2
python-multipart
pypdf
google-generativeai
markdown

```

### app/main.py

```python
from __future__ import annotations

import json
import re
import shutil
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from pathlib import Path
from typing import Any, Optional

import markdown
from fastapi import FastAPI, Request, UploadFile, File, Form, HTTPException, BackgroundTasks
from fastapi.responses import HTMLResponse, RedirectResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from pypdf import PdfReader

from .llm import PROMPT_SPECS, run_llm

APP_DIR = Path(__file__).resolve().parent
UPLOADS_DIR = APP_DIR / "uploads"
CACHE_DIR = APP_DIR / "cache"
INDEX_PATH = APP_DIR.parent / "index.json"
TEMPLATES_DIR = APP_DIR / "templates"
STATIC_DIR = APP_DIR / "static"

app = FastAPI()
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")


# -------------------------
# Index helpers (folder DB)
# -------------------------

def _load_index() -> dict:
    if not INDEX_PATH.exists():
        return {"syllabi": []}
    return json.loads(INDEX_PATH.read_text())


def _save_index(idx: dict) -> None:
    INDEX_PATH.write_text(json.dumps(idx, indent=2))


def _slugify(s: str) -> str:
    s = s.lower().strip()
    s = re.sub(r"[^a-z0-9]+", "-", s)
    return s.strip("-")


def _unique_slug(base: str, existing: set[str]) -> str:
    slug = base
    i = 2
    while slug in existing:
        slug = f"{base}-{i}"
        i += 1
    return slug


def _find_meta(slug: str) -> Optional[dict[str, Any]]:
    idx = _load_index()
    for s in idx.get("syllabi", []):
        if s.get("slug") == slug:
            return s
    return None


def _prune_missing_files() -> None:
    idx = _load_index()
    kept = []
    changed = False

    for s in idx.get("syllabi", []):
        filename = s.get("filename")
        if not filename:
            changed = True
            continue

        if (UPLOADS_DIR / filename).exists():
            kept.append(s)
        else:
            changed = True

    if changed:
        idx["syllabi"] = kept
        _save_index(idx)


# -------------------------
# PDF helpers
# -------------------------

def _extract_pdf_text(pdf_path: Path, max_chars: int = 45_000) -> str:
    """Best-effort PDF -> text. Truncates to keep LLM calls small."""
    try:
        reader = PdfReader(str(pdf_path))
        parts: list[str] = []
        total = 0
        for page in reader.pages:
            t = page.extract_text() or ""
            if t:
                parts.append(t)
                total += len(t)
            if total >= max_chars:
                break
        return "\n\n".join(parts)[:max_chars]
    except Exception:
        return ""


def _cache_file(slug: str, prompt_key: str) -> Path:
    safe = re.sub(r"[^a-zA-Z0-9_-]+", "_", prompt_key)
    return CACHE_DIR / f"{slug}__{safe}.txt"


# -------------------------
# Precompute (upload-time)
# -------------------------

def _precompute_insights(slug: str, filename: str) -> None:
    """
    Run all prompt queries once at upload time and write cache files.
    Extracts PDF text once. Uses a small thread pool to parallelize LLM calls.
    """
    pdf_path = UPLOADS_DIR / filename
    text = _extract_pdf_text(pdf_path)

    prompt_keys = list(PROMPT_SPECS.keys())

    if not text.strip():
        msg = "No text could be extracted from this PDF (scanned image?)."
        for k in prompt_keys:
            _cache_file(slug, k).write_text(msg)
        return

    def run_one(k: str) -> tuple[str, str]:
        out = run_llm(k, text)
        return k, out

    max_workers = max(1, min(4, len(prompt_keys)))

    with ThreadPoolExecutor(max_workers=max_workers) as ex:
        futures = [ex.submit(run_one, k) for k in prompt_keys]
        for f in as_completed(futures):
            k, out = f.result()
            _cache_file(slug, k).write_text(out)


# -------------------------
# App lifecycle
# -------------------------

@app.on_event("startup")
def _startup() -> None:
    UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
    CACHE_DIR.mkdir(parents=True, exist_ok=True)
    if not INDEX_PATH.exists():
        _save_index({"syllabi": []})
    _prune_missing_files()


# -------------------------
# Routes
# -------------------------

@app.get("/", response_class=HTMLResponse)
def index(request: Request):
    _prune_missing_files()
    idx = _load_index()
    return templates.TemplateResponse(
        "index.html",
        {"request": request, "syllabi": idx.get("syllabi", [])},
    )


@app.get("/upload", response_class=HTMLResponse)
def upload_form(request: Request):
    return templates.TemplateResponse("upload.html", {"request": request})


@app.post("/upload")
def upload_syllabus(
    background_tasks: BackgroundTasks,
    course_code: str = Form(...),
    title: str = Form(""),
    instructor: str = Form(""),
    quarter: str = Form(""),
    year: int = Form(0),
    pdf: UploadFile = File(...),
):
    if not pdf.filename.lower().endswith(".pdf"):
        raise HTTPException(status_code=400, detail="Upload must be a PDF")

    idx = _load_index()
    existing = {s["slug"] for s in idx.get("syllabi", [])}

    base_slug = _slugify(f"{course_code}-{instructor}-{quarter}-{year}")
    slug = _unique_slug(base_slug, existing)

    filename = f"{slug}.pdf"
    pdf_path = UPLOADS_DIR / filename
    with pdf_path.open("wb") as f:
        shutil.copyfileobj(pdf.file, f)

    idx["syllabi"].append(
        {
            "slug": slug,
            "course_code": course_code,
            "title": title,
            "instructor": instructor,
            "quarter": quarter,
            "year": year,
            "filename": filename,
            "uploaded_at": datetime.utcnow().isoformat(),
        }
    )
    _save_index(idx)

    # Kick off precompute in the background (fast upload -> instant tabs later)
    background_tasks.add_task(_precompute_insights, slug, filena
[truncated — 11604 more characters]
```

### app/llm.py

```python
from __future__ import annotations

import os
import json
from google import genai

API_KEY = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
if not API_KEY:
    raise RuntimeError("Missing GEMINI_API_KEY (or GOOGLE_API_KEY)")

client = genai.Client(api_key=API_KEY)

MODEL_NAME = os.environ.get("GEMINI_MODEL", "gemini-2.5-flash")  # good default  [oai_citation:3‡Google AI for Developers](https://ai.google.dev/gemini-api/docs/models?utm_source=chatgpt.com)

PROMPT_SPECS: dict[str, dict] = {
    "tldr": {
        "mode": "text",
        "prompt": "Summarize this syllabus in 6 concise bullet points."
    },
    "workload": {
        "mode": "json",
        "prompt": "Estimate workload and identify heavy weeks from the syllabus.",
        "schema": {
            "hours_per_week_estimate": None,
            "workload_shape": None,
            "heavy_weeks": [],
            "why_heavy": "",
            "evidence_quotes": []
        }
    },
    "grading": {
        "mode": "json",
        "prompt": "Extract grading breakdown and major deliverables from the syllabus.",
        "schema": {
            "grading_components": [],
            "deliverables": [],
            "late_policy": "",
            "collaboration_policy": "",
            "evidence_quotes": []
        }
    },
    "prereqs": {
        "mode": "json",
        "prompt": "Infer implied prerequisites and recommended background from the syllabus text.",
        "schema": {
            "official_prereqs": [],
            "implied_background": [],
            "tools_languages": [],
            "math_background": [],
            "evidence_quotes": []
        }
    },
}

def run_llm(prompt_key: str, syllabus_text: str) -> str:
    # ---- RAW PROMPT MODE (e.g. compare) ----
    if prompt_key not in PROMPT_SPECS:
        try:
            resp = client.models.generate_content(
                model=MODEL_NAME,
                contents=syllabus_text,
            )
            return (resp.text or "").strip()
        except Exception as e:
            return f"LLM error: {str(e)}"

    # ---- STRUCTURED PROMPT MODE ----
    spec = PROMPT_SPECS[prompt_key]

    if not syllabus_text.strip():
        return "No text could be extracted from this PDF (or it’s scanned)."

    if spec["mode"] == "text":
        prompt = _wrap_text(spec["prompt"], syllabus_text)
        resp = client.models.generate_content(
            model=MODEL_NAME,
            contents=prompt,
        )
        return (resp.text or "").strip()

    # JSON mode
    schema_json = json.dumps(spec["schema"], indent=2)
    prompt = _wrap_json(spec["prompt"], schema_json, syllabus_text)
    resp = client.models.generate_content(
        model=MODEL_NAME,
        contents=prompt,
    )
    out = (resp.text or "").strip()

    parsed = _extract_json(out)
    return json.dumps(parsed, indent=2, ensure_ascii=False) if parsed is not None else out

    
def _wrap_text(task: str, text: str) -> str:
    return f"""You analyze university course syllabi.

TASK:
{task}

SYLLABUS TEXT:
{text}
"""

def _wrap_json(task: str, schema_json: str, text: str) -> str:
    return f"""You are a careful information extraction system for university syllabi.

TASK:
{task}

OUTPUT:
Return ONLY valid JSON matching this template. Use null/empty arrays when unknown. Do not invent facts.
JSON TEMPLATE:
{schema_json}

RULES:
- evidence_quotes: short verbatim snippets (<=25 words) from the syllabus when possible.
- If the syllabus doesn't say it, leave it null/[]/"".

SYLLABUS TEXT:
{text}
"""

def _extract_json(s: str):
    s2 = s.strip()
    if s2.startswith("```"):
        parts = s2.split("```")
        if len(parts) >= 3:
            s2 = parts[1].strip()
    start = s2.find("{")
    end = s2.rfind("}")
    if start == -1 or end == -1 or end <= start:
        return None
    try:
        return json.loads(s2[start:end+1])
    except Exception:
        return None
```

### app/templates/base.html

```html
<!doctype html>
<html>
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>{% block title %}SlugSyllabus{% endblock %}</title>

  <!-- Tailwind CDN for a good-looking demo with zero build tooling -->
  <script src="https://cdn.tailwindcss.com"></script>

  <!-- HTMX for "SPA-like" tabs without React -->
  <script src="https://unpkg.com/htmx.org@1.9.12"></script>

  <link rel="stylesheet" href="/static/app.css" />
</head>

<body class="bg-slate-950 text-slate-100">
  <header class="sticky top-0 z-50 border-b border-slate-800 bg-slate-950/70 backdrop-blur">
    <div class="mx-auto flex max-w-5xl items-center justify-between px-4 py-3">
      <a class="font-semibold tracking-tight" href="/">
        <span class="text-slate-100">Slug</span><span class="text-emerald-400">Syllabus</span>
      </a>
      <nav class="flex items-center gap-3 text-sm">
        <a class="rounded-lg px-3 py-2 hover:bg-slate-900" href="/">Browse</a>
        <a class="rounded-lg px-3 py-2 bg-emerald-500/10 text-emerald-300 hover:bg-emerald-500/15" href="/upload">Upload</a>
      </nav>
    </div>
  </header>

  <main class="mx-auto max-w-5xl px-4 py-6">
    {% block content %}{% endblock %}
  </main>
</body>
</html>

```

### app/static/app.css

```css
.mono {
  white-space: pre-wrap;
  word-break: break-word;
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
  font-size: 12.5px;
  line-height: 1.55;
  color: #e2e8f0;
}


/* ===== LLM OUTPUT ===== */

.llm-card {
  background: #0b1220;
  border: 1px solid #1e293b;
  border-radius: 12px;
  padding: 20px 24px;
  margin-top: 12px;
}

.llm-content {
  max-width: 900px;
  line-height: 1.6;
  color: #e5e7eb;
}

/* Headings */
.llm-content h1,
.llm-content h2,
.llm-content h3 {
  margin-top: 1.2em;
  margin-bottom: 0.4em;
  font-weight: 600;
}

.llm-content h2 {
  font-size: 1.2rem;
  border-bottom: 1px solid #1e293b;
  padding-bottom: 4px;
}

/* Lists */
.llm-content ul {
  margin-left: 1.25rem;
  list-style: disc;
}

.llm-content li {
  margin: 0.3em 0;
}

/* Tables */
.llm-content table {
  border-collapse: collapse;
  margin: 12px 0;
  width: 100%;
}

.llm-content th,
.llm-content td {
  border: 1px solid #1e293b;
  padding: 8px 10px;
}

.llm-content th {
  background: #020617;
  font-weight: 600;
}

/* Code blocks */
.llm-content pre {
  background: #020617;
  padding: 12px;
  border-radius: 8px;
  overflow-x: auto;
}

.llm-content code {
  background: #020617;
  padding: 2px 5px;
  border-radius: 4px;
}

/* Blockquotes */
.llm-content blockquote {
  border-left: 3px solid #38bdf8;
  padding-left: 12px;
  color: #cbd5f5;
  margin: 12px 0;
}

```

### app/templates/index.html

```html
{% extends "base.html" %}
{% block title %}Browse | SlugSyllabus{% endblock %}

{% block content %}
<div class="flex items-end justify-between gap-4">
  <div>
    <h1 class="text-2xl font-semibold tracking-tight">Browse syllabi</h1>
    <p class="mt-1 text-sm text-slate-400">Folder-only storage. Insights generated on demand.</p>
  </div>
</div>

<form class="mt-5 flex gap-2" method="get" action="/">
  <input
    name="q"
    value="{{ q }}"
    placeholder="Search course code, title, instructor"
    class="w-full rounded-xl border border-slate-800 bg-slate-900/40 px-3 py-2 text-sm outline-none focus:border-emerald-500/60"
  />
  <button class="rounded-xl border border-slate-800 bg-slate-900 px-4 py-2 text-sm hover:bg-slate-800">
    Search
  </button>
</form>

<div class="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2">
  {% for s in syllabi %}
  <a href="/syllabus/{{ s.slug }}" class="group rounded-2xl border border-slate-800 bg-slate-900/30 p-4 hover:bg-slate-900/50">
    <div class="flex items-start justify-between gap-3">
      <div>
        <div class="text-lg font-semibold tracking-tight group-hover:text-emerald-200">
          {{ s.course_code }}{% if s.title %}: {{ s.title }}{% endif %}
        </div>
        <div class="mt-1 text-sm text-slate-400">
          {% if s.instructor %}{{ s.instructor }} · {% endif %}
          {% if s.quarter %}{{ s.quarter }}{% endif %}{% if s.year %} {{ s.year }}{% endif %}
        </div>
      </div>
      <span class="rounded-full border border-slate-800 bg-slate-950 px-2 py-1 text-xs text-slate-300">PDF</span>
    </div>

    <div class="mt-4 flex flex-wrap gap-2">
      <span class="rounded-full bg-emerald-500/10 px-2.5 py-1 text-xs text-emerald-300">On-demand insights</span>
      <span class="rounded-full bg-slate-800/60 px-2.5 py-1 text-xs text-slate-300">No DB</span>
    </div>
  </a>
  {% endfor %}
</div>

{% if syllabi|length == 0 %}
  <div class="mt-10 rounded-2xl border border-slate-800 bg-slate-900/30 p-6">
    <div class="text-sm text-slate-300">No syllabi yet.</div>
    <a href="/upload" class="mt-3 inline-block rounded-xl bg-emerald-500/10 px-4 py-2 text-sm text-emerald-300 hover:bg-emerald-500/15">
      Upload your first PDF →
    </a>
  </div>
{% endif %}
{% endblock %}

```

### app/templates/syllabus.html

```html
{% extends "base.html" %}
{% block title %}{{ s.course_code }} | SlugSyllabus{% endblock %}

{% block content %}
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
  <div>
    <h1 class="text-2xl font-semibold tracking-tight">
      {{ s.course_code }}{% if s.title %}: {{ s.title }}{% endif %}
    </h1>
    <p class="mt-1 text-sm text-slate-400">
      {% if s.instructor %}{{ s.instructor }} · {% endif %}
      {% if s.quarter %}{{ s.quarter }}{% endif %}{% if s.year %} {{ s.year }}{% endif %}
    </p>
  </div>

  <form method="post" action="/cache/clear/{{ s.slug }}">
    <button class="rounded-xl border border-slate-800 bg-slate-900 px-4 py-2 text-sm hover:bg-slate-800">
      Clear insight cache
    </button>
  </form>
</div>

<div class="mt-6 grid grid-cols-1 gap-4 lg:grid-cols-2">
  <!-- Left: Tabs -->
  <div class="rounded-2xl border border-slate-800 bg-slate-900/30 p-4">
    <div class="flex flex-wrap gap-2">
      {% for k in prompt_keys %}
      <button
        class="rounded-xl border border-slate-800 bg-slate-950 px-3 py-2 text-sm hover:bg-slate-900"
        hx-get="/insight/{{ s.slug }}/{{ k }}"
        hx-target="#panel"
        hx-swap="innerHTML"
      >
        {{ k.upper() }}
      </button>
      {% endfor %}
    </div>

    <div id="panel" class="mt-4">
      <div class="rounded-xl border border-slate-800 bg-slate-950 p-4 text-sm text-slate-300">
        Click a tab to generate an insight.
      </div>
    </div>
  </div>

  <!-- Right: PDF viewer -->
  <div class="rounded-2xl border border-slate-800 bg-slate-900/30 p-4">
    <div class="flex items-center justify-between">
      <div class="text-sm text-slate-300">Syllabus PDF</div>
      <a class="text-sm text-emerald-300 hover:text-emerald-200" href="/pdf/{{ s.slug }}" target="_blank">Open in new tab →</a>
    </div>

    <div class="mt-3 overflow-hidden rounded-xl border border-slate-800 bg-slate-950">
      <object data="/pdf/{{ s.slug }}" type="application/pdf" class="h-[70vh] w-full">
  <div class="p-3 text-sm text-slate-300">
    Your browser couldn’t embed the PDF here.
    <a class="text-emerald-300 hover:text-emerald-200" href="/pdf/{{ s.slug }}" target="_blank">Open the PDF in a new tab →</a>
  </div>
</object>
    </div>
  </div>
</div>
{% endblock %}

```

### app/templates/upload.html

```html
{% extends "base.html" %}
{% block title %}Upload | SlugSyllabus{% endblock %}

{% block content %}
<h1 class="text-2xl font-semibold tracking-tight">Upload a syllabus</h1>
<p class="mt-1 text-sm text-slate-400">Stores PDFs in <code class="px-1 py-0.5 rounded bg-slate-900 border border-slate-800">uploads/</code> and metadata in <code class="px-1 py-0.5 rounded bg-slate-900 border border-slate-800">index.json</code>.</p>

<form class="mt-6 grid gap-4 max-w-xl" method="post" action="/upload" enctype="multipart/form-data">
  <div class="grid gap-2">
    <label class="text-sm text-slate-300">Course code</label>
    <input name="course_code" required placeholder="CSE 130"
      class="rounded-xl border border-slate-800 bg-slate-900/40 px-3 py-2 text-sm outline-none focus:border-emerald-500/60" />
  </div>

  <div class="grid gap-2">
    <label class="text-sm text-slate-300">Title (optional)</label>
    <input name="title" placeholder="Principles of Computer Systems Design"
      class="rounded-xl border border-slate-800 bg-slate-900/40 px-3 py-2 text-sm outline-none focus:border-emerald-500/60" />
  </div>

  <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
    <div class="grid gap-2">
      <label class="text-sm text-slate-300">Instructor (optional)</label>
      <input name="instructor" placeholder="Prof. X"
        class="rounded-xl border border-slate-800 bg-slate-900/40 px-3 py-2 text-sm outline-none focus:border-emerald-500/60" />
    </div>
    <div class="grid gap-2">
      <label class="text-sm text-slate-300">Quarter (optional)</label>
      <input name="quarter" placeholder="Fall"
        class="rounded-xl border border-slate-800 bg-slate-900/40 px-3 py-2 text-sm outline-none focus:border-emerald-500/60" />
    </div>
  </div>

  <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
    <div class="grid gap-2">
      <label class="text-sm text-slate-300">Year (optional)</label>
      <input name="year" type="number" placeholder="2026"
        class="rounded-xl border border-slate-800 bg-slate-900/40 px-3 py-2 text-sm outline-none focus:border-emerald-500/60" />
    </div>
    <div class="grid gap-2">
      <label class="text-sm text-slate-300">Syllabus PDF</label>
      <input name="pdf" type="file" accept="application/pdf" required
        class="rounded-xl border border-slate-800 bg-slate-900/40 px-3 py-2 text-sm" />
    </div>
  </div>

  <button class="mt-2 rounded-xl bg-emerald-500/10 px-4 py-2 text-sm text-emerald-300 hover:bg-emerald-500/15 border border-emerald-500/20">
    Upload →
  </button>
</form>
{% endblock %}

```