# Project export: Phantasm

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: Find out if your syllabus is a lie
- Devpost: https://devpost.com/software/phantasm
- GitHub: https://github.com/Pandawiththewin/phantasm
- Video: https://www.youtube.com/embed/Hw5hV534KR0?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 0 GitHub contributor(s) — 

## Devpost submission (written by the team)

No Devpost description available.

## README (from the GitHub repository)

<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>

# Run and deploy your AI Studio app

This contains everything you need to run your app locally.

View your app in AI Studio: https://ai.studio/apps/drive/1HKP1ALCtLo2THvt1QTvPdrZS_fB8VGop

## Run Locally

**Prerequisites:**  Node.js


1. Install dependencies:
   `npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
   `npm run dev`
#   p h a n t a s m  
 

## Detected evidence (automated analysis)

Indexed codebase: 15 recognized source files, 82 KB.
- HTML (language) — detected in the code
- React (technology) — detected in the code
- TypeScript (language) — detected in the code
- CSS (language) — claimed on Devpost, not found in the code
- Google Gemini (technology) — claimed on Devpost, not found in the code

## Codebase structure (from repository index)

### Files (19 of 19)

```
.gitignore
App.tsx
components/CramTramView.tsx
components/GhostInput.tsx
components/PhantomRadio.tsx
components/RatingCard.tsx
components/SettingsModal.tsx
components/SyllabusView.tsx
index.html
index.tsx
metadata.json
package.json
README.md
services/geminiService.ts
services/openNoteService.ts
services/redditService.ts
tsconfig.json
types.ts
vite.config.ts
```

### Dependencies

- package.json: @google/genai@^1.37.0, @types/node@^22.14.0, @vitejs/plugin-react@^5.0.0, lucide-react@^0.562.0, react@^19.2.3, react-dom@^19.2.3, typescript@~5.8.2, vite@^6.2.0

### Recent commits (newest first)

- Update project files
- first commit

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

### package.json

```
{
  "name": "phantasm:-ghost-syllabus",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "@google/genai": "^1.37.0",
    "lucide-react": "^0.562.0",
    "react": "^19.2.3",
    "react-dom": "^19.2.3"
  },
  "devDependencies": {
    "@types/node": "^22.14.0",
    "@vitejs/plugin-react": "^5.0.0",
    "typescript": "~5.8.2",
    "vite": "^6.2.0"
  }
}

```

### index.tsx

```typescript
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';

const rootElement = document.getElementById('root');
if (!rootElement) {
  throw new Error("Could not find root element to mount to");
}

const root = ReactDOM.createRoot(rootElement);
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);
```

### App.tsx

```typescript
import React, { useState } from 'react';
import { Loader2, Sparkles, AlertOctagon, GraduationCap, BookOpen, User, Ghost, ImagePlus, X, FileText, Film, Settings, TrainFront } from 'lucide-react';
import GhostInput from './components/GhostInput';
import SyllabusView from './components/SyllabusView';
import CramTramView from './components/CramTramView';
import RatingCard from './components/RatingCard';
import PhantomRadio from './components/PhantomRadio';
import SettingsModal from './components/SettingsModal';
import { fetchRedditData } from './services/redditService';
import { generateSyllabus, getProfessorRating } from './services/geminiService';
import { FetchStatus, ProfessorRating } from './types';

const App: React.FC = () => {
  if (!process.env.API_KEY) {
    return (
      <div className="min-h-screen bg-paper flex flex-col items-center justify-center p-4 font-typewriter">
        <div className="bg-paper border-[4px] border-ink p-8 max-w-md text-center shadow-[8px_8px_0px_0px_rgba(10,10,10,1)]">
          <AlertOctagon className="w-16 h-16 text-ink mx-auto mb-4 stroke-[3]" />
          <h1 className="text-2xl font-display uppercase mb-4">Reel Missing!</h1>
          <p className="text-ink text-sm">Please insert your <code className="bg-ink text-paper px-1">GEMINI_API_KEY</code> to begin the show.</p>
        </div>
      </div>
    );
  }

  const [university, setUniversity] = useState('');
  const [courseCode, setCourseCode] = useState('');
  const [professor, setProfessor] = useState('');
  const [status, setStatus] = useState<FetchStatus>(FetchStatus.IDLE);
  const [syllabus, setSyllabus] = useState<string | null>(null);
  const [rating, setRating] = useState<ProfessorRating | null>(null);
  const [source, setSource] = useState<'LIVE' | 'MOCK'>('LIVE');
  const [error, setError] = useState<string | null>(null);
  const [selectedFile, setSelectedFile] = useState<File | null>(null);
  const [isSettingsOpen, setIsSettingsOpen] = useState(false);
  
  // View State for Cram Tram
  const [view, setView] = useState<'DASHBOARD' | 'CRAM_TRAM'>('DASHBOARD');

  const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (e.target.files && e.target.files[0]) {
      setSelectedFile(e.target.files[0]);
    }
  };

  const handleGenerate = async () => {
    if (!courseCode.trim() || !university.trim()) return;
    setStatus(FetchStatus.LOADING);
    setSyllabus(null);
    setRating(null);
    setError(null);

    try {
      const redditPromise = fetchRedditData(university, courseCode, professor);
      const ratingPromise = professor && professor.trim() 
        ? getProfessorRating(university, professor) 
        : Promise.resolve(null);

      const [redditRes, ratingRes] = await Promise.all([redditPromise, ratingPromise]);
      setSource(redditRes.source);
      setRating(ratingRes);

      const generatedSyllabus = await generateSyllabus(university, courseCode, redditRes.data, professor, selectedFile);
      setSyllabus(generatedSyllabus);
      setStatus(FetchStatus.SUCCESS);
    } catch (err: any) {
      console.error(err);
      setError(err.message || "The projector jammed (Connection failed).");
      setStatus(FetchStatus.ERROR);
    }
  };

  // If in Cram Tram mode, show that component exclusively
  if (view === 'CRAM_TRAM') {
    return (
      <CramTramView 
        courseCode={courseCode || "Unknown Course"} 
        syllabusContext={syllabus}
        onBack={() => setView('DASHBOARD')}
      />
    );
  }

  return (
    <div className="min-h-screen bg-transparent text-ink font-typewriter flex flex-col">
      <SettingsModal isOpen={isSettingsOpen} onClose={() => setIsSettingsOpen(false)} />
      
      {/* Top Header Card */}
      <header className="pt-8 pb-4 px-4 md:px-8">
        <div className="max-w-7xl mx-auto bg-paper border-[4px] border-ink p-4 shadow-[8px_8px_0px_0px_rgba(10,10,10,1)] rounded-2xl flex flex-col md:flex-row items-center justify-between gap-6 relative overflow-hidden">
             
             {/* Title Block */}
            <div className="flex items-center gap-4 z-10 animate-film-jitter">
              <div className="bg-ink p-3 rounded-full border-2 border-paper ring-4 ring-ink animate-bounce">
                <Ghost className="w-8 h-8 text-paper stroke-[3]" />
              </div>
              <div>
                <h1 className="text-4xl md:text-5xl font-display tracking-wide uppercase text-ink drop-shadow-sm">PHANTASM</h1>
                <p className="text-xs font-bold uppercase tracking-[0.3em] border-t-2 border-ink pt-1 mt-1">The Ghost Syllabus</p>
              </div>
            </div>

            {/* Controls */}
            <div className="flex-1 w-full max-w-4xl grid grid-cols-1 md:grid-cols-12 gap-4 z-10">
                 <div className="md:col-span-3">
                   <GhostInput 
                      value={university} 
                      onChange={setUniversity} 
                      onEnter={() => {}} 
                      disabled={status === FetchStatus.LOADING}
                      placeholder="University"
                      label="Location"
                      icon={GraduationCap}
                    />
                 </div>
                 <div className="md:col-span-3">
                    <GhostInput 
                      value={courseCode} 
                      onChange={setCourseCode} 
                      onEnter={() => {}}
                      disabled={status === FetchStatus.LOADING}
                      placeholder="Course ID"
                      label="Subject"
                      icon={BookOpen}
                    />
                 </div>
                 <div className="md:col-span-3">
                     <GhostInput 
                      value={professor} 
                      onChange={setProfessor} 
                      onEnter={handleGenerate}
                      disabled={status === FetchStatus.LOADING}
                      placeholder="Professor"

[truncated — 6173 more characters]
```

### vite.config.ts

```typescript
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig(({ mode }) => {
    const env = loadEnv(mode, '.', '');
    return {
      server: {
        port: 3000,
        host: '0.0.0.0',
      },
      plugins: [react()],
      define: {
        'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
        'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
      },
      resolve: {
        alias: {
          '@': path.resolve(__dirname, '.'),
        }
      }
    };
});

```

### types.ts

```typescript

export interface SyllabusData {
  courseCode: string;
  content: string;
  source: 'LIVE' | 'MOCK';
  timestamp: string;
}

export enum FetchStatus {
  IDLE = 'IDLE',
  LOADING = 'LOADING',
  SUCCESS = 'SUCCESS',
  ERROR = 'ERROR'
}

export interface RedditResponse {
  data: {
    children: Array<{
      data: {
        title: string;
        selftext: string;
        subreddit?: string;
      }
    }>
  }
}

export interface ProfessorRating {
  found: boolean;
  quality: string;
  difficulty: string;
  takeAgain: string;
  summary: string;
  name?: string;
}

// --- DOOMSDAY PROTOCOL TYPES ---

export interface CramItem {
  timeblock: string;
  action: string;
  priority: string;
  notes: string;
  videoSuggestion?: {
    title: string;
    url: string;
  };
}

export interface CramPlan {
  id?: number; // From OpenNote
  examType: string; // Midterm, Final, etc.
  totalHours: string;
  strategy: string; // The "Surgeon's" overall advice
  schedule: CramItem[];
  createdTs?: number;
}
```

### index.html

```html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Phantasm: The Ghost Syllabus</title>
    <script src="https://cdn.tailwindcss.com"></script>
    <link href="https://fonts.googleapis.com/css2?family=Limelight&family=Courier+Prime:wght@400;700&display=swap" rel="stylesheet">
    <script>
      tailwind.config = {
        theme: {
          extend: {
            colors: {
              ink: '#0a0a0a',
              paper: '#f4f1ea',
              'paper-dark': '#e6e2d6',
              silver: '#a3a3a3',
            },
            fontFamily: {
              display: ['Limelight', 'cursive'],
              typewriter: ['Courier Prime', 'monospace'],
            },
            backgroundImage: {
              'stripes': 'repeating-linear-gradient(45deg, #0a0a0a 0, #0a0a0a 1px, transparent 0, transparent 50%)',
            },
            animation: {
              'film-jitter': 'jitter 0.2s infinite',
              'grain': 'grain 0.5s steps(5) infinite',
              'pop': 'pop 0.3s cubic-bezier(0.16, 1, 0.3, 1)',
            },
            keyframes: {
              jitter: {
                '0%': { transform: 'translate(0, 0)' },
                '25%': { transform: 'translate(1px, 1px)' },
                '50%': { transform: 'translate(0, 0)' },
                '75%': { transform: 'translate(-1px, 0)' },
                '100%': { transform: 'translate(0, 0)' },
              },
              grain: {
                '0%, 100%': { transform: 'translate(0, 0)' },
                '10%': { transform: 'translate(-5%, -10%)' },
                '20%': { transform: 'translate(-15%, 5%)' },
                '30%': { transform: 'translate(7%, -25%)' },
                '40%': { transform: 'translate(-5%, 25%)' },
                '50%': { transform: 'translate(-15%, 10%)' },
                '60%': { transform: 'translate(15%, 0%)' },
                '70%': { transform: 'translate(0%, 15%)' },
                '80%': { transform: 'translate(3%, 35%)' },
                '90%': { transform: 'translate(-10%, 10%)' },
              },
              pop: {
                '0%': { transform: 'scale(0.95)' },
                '50%': { transform: 'scale(1.02)' },
                '100%': { transform: 'scale(1)' },
              }
            }
          }
        }
      }
    </script>
  <script type="importmap">
{
  "imports": {
    "@google/genai": "https://esm.sh/@google/genai@^1.37.0",
    "lucide-react": "https://esm.sh/lucide-react@^0.562.0",
    "react/": "https://esm.sh/react@^19.2.3/",
    "react": "https://esm.sh/react@^19.2.3",
    "react-dom/": "https://esm.sh/react-dom@^19.2.3/"
  }
}
</script>
<style>
  /* Film Grain Overlay */
  .film-grain {
    position: fixed;
    top: -50%;
    left: -50%;
    width: 200%;
    height: 200%;
    background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)' opacity='0.4'/%3E%3C/svg%3E");
    opacity: 0.15;
    pointer-events: none;
    z-index: 9999;
    animation: grain 0.5s steps(4) infinite;
  }

  .vignette {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    box-shadow: 0 0 200px rgba(0,0,0,0.7) inset;
    pointer-events: none;
    z-index: 9998;
  }

  /* Custom Scrollbar - Retro */
  ::-webkit-scrollbar {
    width: 12px;
  }
  ::-webkit-scrollbar-track {
    background: #f4f1ea; 
    border-left: 2px solid #0a0a0a;
  }
  ::-webkit-scrollbar-thumb {
    background: #0a0a0a; 
    border: 2px solid #f4f1ea;
    border-radius: 6px;
  }
  ::-webkit-scrollbar-thumb:hover {
    background: #333; 
  }
</style>
<link rel="stylesheet" href="/index.css">
</head>
  <body class="bg-paper text-ink font-typewriter antialiased selection:bg-ink selection:text-paper overflow-x-hidden">
    <div class="film-grain"></div>
    <div class="vignette"></div>
    <div id="root" class="relative z-10"></div>
  <script type="module" src="/index.tsx"></script>
</body>
</html>
```

### components/GhostInput.tsx

```typescript
import React, { useState } from 'react';
import { Search, LucideIcon } from 'lucide-react';

interface GhostInputProps {
  value: string;
  onChange: (val: string) => void;
  onEnter: () => void;
  disabled?: boolean;
  placeholder?: string;
  icon?: LucideIcon;
  label?: string;
}

const GhostInput: React.FC<GhostInputProps> = ({ 
  value, 
  onChange, 
  onEnter, 
  disabled, 
  placeholder = "Enter...", 
  icon: Icon = Search,
  label
}) => {
  const [isFocused, setIsFocused] = useState(false);

  return (
    <div className="flex flex-col gap-1 w-full">
      {label && (
        <label className="text-xs font-display tracking-widest uppercase ml-1 text-ink">{label}</label>
      )}
      <div className={`relative flex items-center bg-paper transition-all duration-200 rounded-lg overflow-hidden h-12
        border-[3px] shadow-[4px_4px_0px_0px_rgba(10,10,10,1)]
        ${isFocused 
          ? 'border-ink translate-x-[1px] translate-y-[1px] shadow-[2px_2px_0px_0px_rgba(10,10,10,1)]' 
          : 'border-ink'
        }
        ${disabled ? 'opacity-50 grayscale cursor-not-allowed' : ''}
      `}>
        <div className={`pl-3 flex items-center pointer-events-none text-ink`}>
          <Icon className="h-5 w-5 stroke-[3]" />
        </div>
        
        <input
          type="text"
          value={value}
          onChange={(e) => onChange(e.target.value)}
          onKeyDown={(e) => e.key === 'Enter' && onEnter()}
          onFocus={() => setIsFocused(true)}
          onBlur={() => setIsFocused(false)}
          disabled={disabled}
          placeholder={placeholder}
          className="w-full pl-2 pr-3 py-3 bg-transparent border-none text-ink placeholder-silver font-bold font-typewriter
                     focus:ring-0 focus:outline-none text-base uppercase"
        />
      </div>
    </div>
  );
};

export default GhostInput;
```

### services/redditService.ts

```typescript
import { RedditResponse } from '../types';

const MOCK_REVIEWS = [
  "The midterm is identical to the practice exam. Don't waste time on the textbook, just grind the past papers.",
  "Warning: The professor loves trick questions on 'exceptions to the rule'. Memorize the edge cases.",
  "The group project is 40% of the grade. If you get bad teammates, go to office hours immediately.",
  "Lectures are recorded but the audio is terrible. You actually have to go to class to hear the examples.",
  "Avoid the 8am section, the TA for the afternoon slot is way more helpful with the labs.",
  "They curve the final heavily because the average is usually around 55%. Don't panic if you fail the first quiz.",
  "The textbook PDF is in the class Discord. Do not buy it.",
  "Week 7 is the 'Panic Zone'. The workload triples out of nowhere."
];

const getMockData = (university: string, courseCode: string, professor?: string): string => {
  const profString = professor ? ` taught by ${professor}` : '';
  const header = `[SIMULATION MODE ACTIVE] Could not reach Reddit. Generated realistic student chatter for ${courseCode} at ${university}${profString}:`;
  
  const comments = MOCK_REVIEWS.map(review => {
    // Randomize slightly to make it feel less static
    return `- [r/${university}Student] ${review}`;
  }).join('\n');

  return `${header}\n\n${comments}`;
};

// Helper to process raw JSON into our string format
const processRedditJson = (json: any): string => {
  if (!json.data || !json.data.children || json.data.children.length === 0) {
    throw new Error("No results found for this course.");
  }

  return json.data.children.map((child: any) => {
    const sub = child.data.subreddit_name_prefixed || child.data.subreddit || 'r/college';
    const title = child.data.title;
    const text = child.data.selftext ? child.data.selftext.substring(0, 500) + "..." : "(No text content)";
    return `[Source: ${sub}] Title: ${title}\nDiscussion: ${text}`;
  }).join('\n\n');
};

export const fetchRedditData = async (university: string, courseCode: string, professor?: string): Promise<{ data: string; source: 'LIVE' | 'MOCK' }> => {
  // Construct Query
  const queryParts = [university, courseCode];
  if (professor && professor.trim()) queryParts.push(professor);
  const query = queryParts.join(' ');
  
  // Reddit API Endpoint
  const redditUrl = `https://www.reddit.com/search.json?q=${encodeURIComponent(query)}&sort=relevance&limit=10&type=link`;

  // Proxy List - We try them in order
  const proxies = [
    // Proxy 1: corsproxy.io (Generally fast and reliable)
    { 
      url: `https://corsproxy.io/?${encodeURIComponent(redditUrl)}`,
      transform: async (res: Response) => await res.json()
    },
    // Proxy 2: allorigins.win (Reliable fallback, but needs JSON parsing of 'contents')
    { 
      url: `https://api.allorigins.win/get?url=${encodeURIComponent(redditUrl)}`,
      transform: async (res: Response) => {
        const wrapper = await res.json();
        return JSON.parse(wrapper.contents);
      }
    }
  ];

  for (const proxy of proxies) {
    try {
      console.log(`Attempting fetch via proxy...`);
      const response = await fetch(proxy.url);
      
      if (!response.ok) throw new Error(`Status ${response.status}`);
      
      const json = await proxy.transform(response);
      const data = processRedditJson(json);
      
      return { data, source: 'LIVE' };
    } catch (e) {
      console.warn(`Proxy failed:`, e);
      // Continue to next proxy loop
    }
  }

  // If all proxies fail:
  console.warn("All proxies failed. Switching to Mock Data.");
  return { data: getMockData(university, courseCode, professor), source: 'MOCK' };
};
```

### components/SettingsModal.tsx

```typescript
import React, { useState, useEffect } from 'react';
import { X, BookLock, HelpCircle, Save, CheckSquare, Terminal, ExternalLink } from 'lucide-react';
import { HARDCODED_LEDGER_CONFIG } from '../services/openNoteService';

interface SettingsModalProps {
  isOpen: boolean;
  onClose: () => void;
}

const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose }) => {
  const [ledgerUrl, setLedgerUrl] = useState('');
  const [ledgerToken, setLedgerToken] = useState('');
  const [status, setStatus] = useState<'IDLE' | 'SAVED'>('IDLE');

  // Load from storage when modal opens, or fall back to hardcoded defaults
  useEffect(() => {
    if (isOpen) {
      setLedgerUrl(localStorage.getItem('phantasm_ledger_url') || HARDCODED_LEDGER_CONFIG.url);
      setLedgerToken(localStorage.getItem('phantasm_ledger_token') || HARDCODED_LEDGER_CONFIG.token);
    }
  }, [isOpen]);

  const handleSave = () => {
    localStorage.setItem('phantasm_ledger_url', ledgerUrl);
    localStorage.setItem('phantasm_ledger_token', ledgerToken);
    setStatus('SAVED');
    setTimeout(() => {
      setStatus('IDLE');
      onClose();
    }, 1000);
  };

  if (!isOpen) return null;

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-ink/50 backdrop-blur-sm animate-in fade-in">
      <div className="bg-paper border-[4px] border-ink rounded-xl p-6 w-full max-w-lg shadow-[12px_12px_0px_0px_rgba(10,10,10,1)] relative animate-pop max-h-[90vh] overflow-y-auto">
        <button 
          onClick={onClose}
          className="absolute -top-3 -right-3 bg-ink text-paper p-1 rounded-full border-2 border-paper hover:scale-110 transition-transform"
        >
          <X className="w-5 h-5" />
        </button>
        
        <div className="flex items-center gap-3 mb-6">
          <div className="bg-ink p-2 rounded-lg">
            <BookLock className="w-6 h-6 text-paper" />
          </div>
          <div>
            <h3 className="text-xl font-display uppercase">Link the Ledger</h3>
            <p className="text-[10px] font-typewriter uppercase tracking-widest text-ink/60">Connect to Memos Database</p>
          </div>
        </div>
        
        {/* Connection Form */}
        <div className="space-y-5 bg-paper-dark p-4 rounded-lg border-2 border-ink">
          <div>
            <label className="flex items-center gap-2 text-xs font-bold uppercase tracking-wider mb-1">
               Server URL
            </label>
            <input 
              type="text" 
              value={ledgerUrl}
              onChange={(e) => setLedgerUrl(e.target.value)}
              placeholder="http://localhost:5230"
              className="w-full bg-paper border-[2px] border-ink p-2 font-typewriter text-sm rounded shadow-[2px_2px_0px_0px_rgba(10,10,10,1)] focus:outline-none focus:translate-x-[1px] focus:translate-y-[1px] focus:shadow-none transition-all"
            />
          </div>
          <div>
            <label className="block text-xs font-bold uppercase tracking-wider mb-1">Access Token</label>
            <input 
              type="password" 
              value={ledgerToken}
              onChange={(e) => setLedgerToken(e.target.value)}
              placeholder="ey..."
              className="w-full bg-paper border-[2px] border-ink p-2 font-typewriter text-sm rounded shadow-[2px_2px_0px_0px_rgba(10,10,10,1)] focus:outline-none focus:translate-x-[1px] focus:translate-y-[1px] focus:shadow-none transition-all"
            />
          </div>

          <button 
            onClick={handleSave}
            className="w-full h-12 bg-ink text-paper font-display text-lg uppercase tracking-widest border-2 border-transparent hover:bg-paper hover:text-ink hover:border-ink transition-colors flex items-center justify-center gap-2 shadow-[4px_4px_0px_0px_rgba(255,255,255,0.5)] active:shadow-none active:translate-y-1"
          >
            {status === 'SAVED' ? 'Linked Successfully' : 'Save Connection'}
            {status === 'SAVED' ? <CheckSquare className="w-5 h-5" /> : <Save className="w-5 h-5" />}
          </button>
        </div>

        {/* Setup Guide */}
        <div className="mt-6 border-t-2 border-ink pt-4">
           <h4 className="font-display uppercase text-sm mb-3 flex items-center gap-2">
             <Terminal className="w-4 h-4" /> No Database? Run this:
           </h4>
           
           <div className="bg-black text-green-400 p-3 rounded font-mono text-xs overflow-x-auto whitespace-nowrap mb-2 border-2 border-ink">
             docker run -d -p 5230:5230 neosmemo/memos:stable
           </div>
           
           <div className="text-xs text-ink/70 space-y-2 font-typewriter">
             <p>1. Run the command above in your terminal.</p>
             <p>2. Open <a href="http://localhost:5230" target="_blank" className="underline font-bold hover:text-blue-600">http://localhost:5230</a> to create your admin account.</p>
             <p>3. Go to <strong>Settings &rarr; Access Tokens</strong> to generate your key.</p>
           </div>

           <a href="https://github.com/usememos/memos" target="_blank" className="mt-4 flex items-center gap-2 text-xs font-bold uppercase tracking-wider hover:text-blue-600">
             <ExternalLink className="w-3 h-3" /> View Official Memos Documentation
           </a>
        </div>

      </div>
    </div>
  );
};

export default SettingsModal;
```

### services/openNoteService.ts

```typescript
import { CramPlan } from '../types';

export interface OpenNoteConfig {
  serverUrl: string;
  token: string;
}

export const HARDCODED_LEDGER_CONFIG = {
  url: "http://localhost:5230", 
  token: "eyJhbGciOiJIUzI1NiIsImtpZCI6InYxIiwidHlwIjoiSldUIn0.eyJuYW1lIjoiUGFuZGF3aXRodGhld2luIiwiaXNzIjoibWVtb3MiLCJzdWIiOiIxIiwiYXVkIjpbInVzZXIuYWNjZXNzLXRva2VuIl0sImV4cCI6MTc3MTM0Mjg2NywiaWF0IjoxNzY4NzUwODY3fQ.8-STI2MUqTA0jp1AowKfjyr1dkAYetb1NDz-YUzsDFc" 
};

const getConfig = (): OpenNoteConfig => {
  return {
    serverUrl: localStorage.getItem('phantasm_ledger_url') || HARDCODED_LEDGER_CONFIG.url,
    token: localStorage.getItem('phantasm_ledger_token') || HARDCODED_LEDGER_CONFIG.token
  };
};

// Helper to format URL
const getApiUrl = (baseUrl: string): string => {
  let url = baseUrl.trim();
  if (url.endsWith('/')) url = url.slice(0, -1);
  if (!url.startsWith('http')) url = `https://${url}`;
  
  if (!url.endsWith('/api/v1/memos')) {
    return `${url}/api/v1/memos`;
  }
  return url;
};

// Helper to handle Memos API response structure variation
// Some versions return [], others { memos: [] }, others { data: [] }
const getMemosList = (json: any): any[] => {
  if (!json) return [];
  if (Array.isArray(json)) return json;
  if (json.memos && Array.isArray(json.memos)) return json.memos;
  if (json.data && Array.isArray(json.data)) return json.data;
  return [];
};

export interface Whisper {
  id: number;
  content: string;
  createdTs: number;
}

export const fetchWhispers = async (courseCode: string): Promise<Whisper[]> => {
  const config = getConfig();
  if (!config.token) return [];

  const apiUrl = getApiUrl(config.serverUrl);
  const tag = courseCode.replace(/\s+/g, '');

  try {
    const response = await fetch(`${apiUrl}?limit=100`, {
      headers: {
        'Authorization': `Bearer ${config.token}`,
      }
    });

    if (!response.ok) return [];

    const rawData = await response.json();
    const data = getMemosList(rawData);
    
    const whispers = data.filter((memo: any) => 
      memo.content &&
      memo.content.includes('#Whisper') && 
      memo.content.includes(`#${tag}`)
    ).map((memo: any) => ({
      id: memo.id,
      content: memo.content
        .replace('#Whisper', '')
        .replace(`#${tag}`, '')
        .trim(),
      createdTs: memo.createdTs
    }));

    return whispers;
  } catch (error) {
    console.error("Failed to fetch whispers:", error);
    return [];
  }
};

export const saveWhisper = async (courseCode: string, content: string): Promise<void> => {
  const config = getConfig();
  if (!config.token) throw new Error("Ledger not connected");

  const apiUrl = getApiUrl(config.serverUrl);
  const tag = courseCode.replace(/\s+/g, '');
  
  const memoContent = `${content}\n\n#Whisper #${tag}`;

  try {
    const response = await fetch(apiUrl, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${config.token}`,
      },
      body: JSON.stringify({
        content: memoContent,
        visibility: "PUBLIC" 
      })
    });

    if (!response.ok) {
      throw new Error(`Server Error: ${response.statusText}`);
    }
  } catch (error) {
    console.error("OpenNote Export Failed:", error);
    throw error;
  }
};

// --- CRAM TRAM (DOOMSDAY) METHODS ---

export const fetchSurvivalPlans = async (courseCode: string): Promise<CramPlan[]> => {
  const config = getConfig();
  if (!config.token) return [];

  const apiUrl = getApiUrl(config.serverUrl);
  const tag = courseCode.replace(/\s+/g, '');

  try {
    const response = await fetch(`${apiUrl}?limit=50`, {
      headers: { 'Authorization': `Bearer ${config.token}` }
    });

    if (!response.ok) return [];

    const rawData = await response.json();
    const data = getMemosList(rawData);
    
    // Filter for #SurvivalPlan
    const plans: CramPlan[] = data
      .filter((memo: any) => 
        memo.content &&
        memo.content.includes('#SurvivalPlan') && 
        memo.content.includes(`#${tag}`)
      )
      .map((memo: any) => {
        // Parse the JSON block inside the memo content
        // The memo format is: #SurvivalPlan #CourseCode\n```json\n{...}\n```
        const jsonMatch = memo.content.match(/```json\n([\s\S]*?)\n```/);
        if (jsonMatch) {
            try {
                const plan = JSON.parse(jsonMatch[1]);
                return { ...plan, id: memo.id, createdTs: memo.createdTs };
            } catch (e) {
                return null;
            }
        }
        return null;
      })
      .filter((p: any) => p !== null);

    return plans;
  } catch (error) {
    console.error("Failed to fetch survival plans", error);
    return [];
  }
};

export const saveSurvivalPlan = async (courseCode: string, plan: CramPlan): Promise<void> => {
  const config = getConfig();
  if (!config.token) throw new Error("Ledger not connected");

  const apiUrl = getApiUrl(config.serverUrl);
  const tag = courseCode.replace(/\s+/g, '');

  // Wrap the plan in markdown code block for Memos to render nicely (and for us to parse back)
  const memoContent = `#SurvivalPlan #${tag}\n\n**Protocol: ${plan.examType} (${plan.totalHours})**\n> ${plan.strategy}\n\n\`\`\`json\n${JSON.stringify(plan, null, 2)}\n\`\`\``;

  try {
    await fetch(apiUrl, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${config.token}`,
      },
      body: JSON.stringify({
        content: memoContent,
        visibility: "PUBLIC"
      })
    });
  } catch (error) {
    console.error("Failed to save survival plan", error);
    throw error;
  }
};
```

[5 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]