# Project export: Dootle

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: Cal Hacks 12.0
- Tagline: Crafting your epic adventure, fairy tale ending, or hero's journey
- Devpost: https://devpost.com/software/dootle
- GitHub: https://github.com/harryyuncheng/dootle
- Video: https://www.youtube.com/embed/2LkDLlnJJmY?enablejsapi=1&hl=en_US&rel=0&start=4&version=3&wmode=transparent
- Team: 3 GitHub contributor(s) — Lucas Kim (29 commits), Harry Cheng (25 commits), Andrea Li (16 commits)

## Devpost submission (written by the team)

### Inspiration

Our team grew up around adventure, creativity, and curiosity. Just as the textbooks we studied contributed to our learning, the novels that we read kept us entertained and shaped who we are. With technology now being introduced to younger ages, and advancements in AI being made at a rapid pace, we wanted to bring our current studies and passions to those of the past. Through Dootle, we hope to provide a tech-forward avenue to greater empower children and parents to channel their creativity and storytelling abilities.

### What it does

Dootle is an interactive children’s storybook maker. Kids (or kids-at-heart) draw a character in the browser, describe the character and a theme, and Dootle generates a 16‑page picture book with consistent illustrations and optional narration. Some of our Features: Drawing canvas and tools Audio narration User account and authentication Storybook bookshelving and downloading

### How we built it

Our frontend was built using React, Tailwind CSS, and NextJS. Our backend was built on Flask and MongoDB, with calls to Gemini, Claude, and ElevenLabs models through OpenRouter. To optimize our process, we used a combination of latency budgeting, prompt engineering, exponential backoff, and parallel computing.

### Challenges we ran into

Something incredibly important to us was the child's ability to see an EXACT copy of their character in our story. It took some clever prompt engineering, extraction, and structuring to achieve this.

### Accomplishments we're proud of

While our team is experienced, we still leveraged this opportunity to learn more about the software development process, and working with generative models. We really love some of the stories our brains created at 2am, and we'd love to share them with you. Additionally, one of our teammates managed to finish two Popeyes chicken sandwiches in one sitting.

### What's next

There are so many paths to build on this project. But first, we want to bring a demo to IOS and collect user feedback to see what features parents and children most desire, and work on those next.

## README (from the GitHub repository)

# dootle

## Installation

### Backend

#### Using `uv`

1. Install uv if you haven't already by running `curl -LsSf https://astral.sh/uv/install.sh | sh` in your terminal if you're on macOS (otherwise, follow the instructions for your OS [here](https://docs.astral.sh/uv/getting-started/installation/#installation-methods)); you will need to restart your terminal after doing this
2. Copy `.env.example` and name the copy `.env`, then fill it in with your environment variables
3. Open the `backend` folder in your terminal
4. Run `uv run main.py` to start the server

### Frontend

1. Open the `frontend` folder in your terminal
2. [Install Node.js and npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) if you haven't already
2. Run `npm install` to install all packages
3. Run `npm run start` to start the Next.js development server


## Detected evidence (automated analysis)

Indexed codebase: 29 recognized source files, 132 KB.
- CSS (language) — detected in the code
- Flask (technology) — detected in the code
- Next.js (technology) — detected in the code
- OpenAI (technology) — detected in the code
- Python (language) — detected in the code
- React (technology) — detected in the code
- Tailwind CSS (technology) — detected in the code
- TypeScript (language) — detected in the code
- Google Gemini (technology) — claimed on Devpost, not found in the code

## Codebase structure (from repository index)

### Files (43 of 43)

```
backend/.env.example
backend/.gitignore
backend/.python-version
backend/main.py
backend/pyproject.toml
backend/README.md
backend/sample.json
backend/story-prompt.txt
backend/uv.lock
frontend/.gitignore
frontend/env.example
frontend/eslint.config.mjs
frontend/next.config.ts
frontend/package.json
frontend/postcss.config.mjs
frontend/README.md
frontend/src/app/api/generate-story/route.ts
frontend/src/app/api/health/route.ts
frontend/src/app/api/session/route.ts
frontend/src/app/globals.css
frontend/src/app/layout.tsx
frontend/src/app/page.tsx
frontend/src/app/sections/DescribeCharacter.tsx
frontend/src/app/sections/DescribeStory.tsx
frontend/src/app/sections/DrawCharacter.tsx
frontend/src/app/sections/Home.tsx
frontend/src/app/sections/Library.tsx
frontend/src/app/sections/Login.tsx
frontend/src/app/sections/Storybook.tsx
frontend/src/app/test/page.tsx
frontend/src/components/Clouds.tsx
frontend/src/components/DrawingCanvas.tsx
frontend/src/components/Library.tsx
frontend/src/components/NavBar.tsx
frontend/src/components/Storybook.tsx
frontend/src/components/Testing.tsx
frontend/src/components/Testings.tsx
frontend/src/config/shelfBounds.json
frontend/src/contexts/AuthContext.tsx
frontend/src/contexts/TransitionContext.tsx
frontend/tsconfig.json
README.md
start.sh
```

### Dependencies

- backend/pyproject.toml: aiohttp@>=3.13.1, elevenlabs@>=2.20.1, flask@>=3.1.2, flask-cors@>=6.0.1, openai@>=2.6.1, python-dotenv@>=1.1.1, reportlab@>=4.4.4, requests@>=2.32.5
- frontend/package.json: @tailwindcss/postcss@^4, @types/node@^20, @types/react@^19, @types/react-dom@^19, axios@^1.12.2, dotenv@^17.2.3, eslint@^9, eslint-config-next@16.0.0, next@16.0.0, react@19.2.0, react-dom@19.2.0, tailwindcss@^4, typescript@^5

### Recent commits (newest first)

- Fix env example
- Merge pull request #27 from harryyuncheng/andrea
- library back to story page
- Merge pull request #26 from harryyuncheng/andrea
- delete jpeg books
- Merge pull request #25 from harryyuncheng/andrea
- library
- Update README with installation instructions
- Merge pull request #24 from harryyuncheng/real-auth
- Upload
- Add auth
- fix parsing
- Add files via upload
- Merge pull request #23 from harryyuncheng/increase-pages
- Fix parsing
- Increase pages
- Add files via upload
- added pencil title and icon
- Merge pull request #22 from harryyuncheng/andrea
- storybook formatting

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

### backend/pyproject.toml

```
[project]
name = "backend"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
    "aiohttp>=3.13.1",
    "elevenlabs>=2.20.1",
    "flask>=3.1.2",
    "flask-cors>=6.0.1",
    "openai>=2.6.1",
    "python-dotenv>=1.1.1",
    "reportlab>=4.4.4",
    "requests>=2.32.5",
]

```

### frontend/package.json

```
{
  "name": "character-story-generator",
  "version": "1.0.0",
  "description": "A web app for drawing characters and generating AI stories",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "eslint"
  },
  "dependencies": {
    "axios": "^1.12.2",
    "dotenv": "^17.2.3",
    "next": "16.0.0",
    "react": "19.2.0",
    "react-dom": "19.2.0"
  },
  "devDependencies": {
    "@tailwindcss/postcss": "^4",
    "@types/node": "^20",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "eslint": "^9",
    "eslint-config-next": "16.0.0",
    "tailwindcss": "^4",
    "typescript": "^5"
  }
}

```

### backend/main.py

```python
from flask import Flask, request, jsonify, send_file
from flask_cors import CORS
from openai import OpenAI
from dotenv import load_dotenv
import os
import requests
import re
import aiohttp
import asyncio
from elevenlabs.client import ElevenLabs
from io import BytesIO
import json

load_dotenv()

app = Flask(__name__)
CORS(app)

# Configuration
MAX_IMAGES = 5  # Maximum number of images to generate per story
PARALLEL_IMAGE_GENERATION = True  # Set to False to generate images sequentially (avoids rate limiting)
IMAGE_GENERATION_MAX_RETRIES = 3  # Number of times to retry image generation on failure
IMAGE_GENERATION_RETRY_DELAY = 5  # Initial delay in seconds between retries (exponential backoff)
TEST_MODE = False  # Set to True to use sample.json instead of making API calls

# Initialize OpenRouter client
client = OpenAI(
    base_url="https://openrouter.ai/api/v1",
    api_key=os.getenv("OPENROUTER_API_KEY"),
)

# Initialize ElevenLabs client
elevenlabs = ElevenLabs(
    api_key=os.getenv("ELEVENLABS_API_KEY"),
)

def load_story_prompt():
    """Load the story prompt template from file."""
    prompt_path = os.path.join(os.path.dirname(__file__), 'story-prompt.txt')
    with open(prompt_path, 'r') as f:
        return f.read()

def load_sample_data():
    """Load sample story data from sample.json."""
    sample_path = os.path.join(os.path.dirname(__file__), 'sample.json')
    with open(sample_path, 'r') as f:
        return json.load(f)

def extract_image_placeholders(story_text):
    """Extract all {description} placeholders from the story."""
    pattern = r'\{([^\}]+)\}'
    matches = re.findall(pattern, story_text)
    return matches

async def generate_image_with_gemini_async(session, description, reference_image_base64, char_description, cover_image_base64=None, is_cover=False):
    """Generate an image using Gemini based on description and reference images (async version with retry logic)."""
    url = "https://openrouter.ai/api/v1/chat/completions"
    headers = {
        "Authorization": f"Bearer {os.getenv('OPENROUTER_API_KEY')}",
        "Content-Type": "application/json"
    }

    # Special prompt for cover to preserve original style
    if is_cover:
        prompt = f"""Generate an image that matches this description: {description}

Character context: {char_description}

CRITICAL: The reference image shows the character's art style. You MUST preserve this EXACT art style in your generation:
- If it's a stick figure, keep it as a stick figure
- If it's a simple sketch, keep it as a simple sketch
- If it's minimalist, keep it minimalist
- Do NOT make it more detailed or "polished" than the original
- Match the line weight, simplicity level, and drawing technique exactly

Think of this as a style transfer - the character and style from the reference image should be recognizable in your output."""
    else:
        # Standard prompt for non-cover images
        prompt = f"""Generate an image that matches this description: {description}

Character context: {char_description}

The character should match the style and appearance of the reference image(s) provided."""

        if cover_image_base64:
            prompt += " Maintain the art style consistent with the cover image provided."

    # Build content array with text and images
    content = [
        {
            "type": "text",
            "text": prompt
        },
        {
            "type": "image_url",
            "image_url": {
                "url": reference_image_base64
            }
        }
    ]

    # Add cover image if provided (for non-cover images)
    if cover_image_base64:
        content.append({
            "type": "image_url",
            "image_url": {
                "url": cover_image_base64
            }
        })

    payload = {
        "model": "google/gemini-2.5-flash-image",
        "messages": [
            {
                "role": "user",
                "content": content
            }
        ],
        "modalities": ["image", "text"],
        "image_config": {
            "aspect_ratio": "16:9"
        }
    }

    # Retry logic with exponential backoff
    for attempt in range(IMAGE_GENERATION_MAX_RETRIES):
        try:
            async with session.post(url, headers=headers, json=payload) as response:
                # Check if response is successful
                if response.status != 200:
                    error_text = await response.text()
                    print(f"HTTP {response.status} error (attempt {attempt + 1}/{IMAGE_GENERATION_MAX_RETRIES}): {error_text[:200]}")

                    if attempt < IMAGE_GENERATION_MAX_RETRIES - 1:
                        delay = IMAGE_GENERATION_RETRY_DELAY * (2 ** attempt)
                        print(f"Retrying in {delay} seconds...")
                        await asyncio.sleep(delay)
                        continue
                    else:
                        return None

                # Try to parse JSON response
                try:
                    result = await response.json()
                except Exception as json_error:
                    error_text = await response.text()
                    print(f"JSON parsing error (attempt {attempt + 1}/{IMAGE_GENERATION_MAX_RETRIES}): {str(json_error)}")
                    print(f"Response text: {error_text[:200]}")

                    if attempt < IMAGE_GENERATION_MAX_RETRIES - 1:
                        delay = IMAGE_GENERATION_RETRY_DELAY * (2 ** attempt)
                        print(f"Retrying in {delay} seconds...")
                        await asyncio.sleep(delay)
                        continue
                    else:
                        return None

                # Check if response has expected structure
                if result.get("choices"):
                    message = result["choices"][0]["message"]
                    if message.get("images") and len(message["images"]) > 0:
                        return message["images"][0]["image_url"]["url
[truncated — 12514 more characters]
```

### frontend/src/app/layout.tsx

```typescript
import type { Metadata } from "next";
import { Geist, Geist_Mono, Schoolbell } from "next/font/google";
import "./globals.css";
import Clouds from "@/components/Clouds";
import { TransitionProvider } from "@/contexts/TransitionContext";
import { AuthProvider } from "@/contexts/AuthContext";

const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});

const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});

const schoolbell = Schoolbell({
  weight: "400",
  variable: "--font-schoolbell",
  subsets: ["latin"],
});

export const metadata: Metadata = {
  title: "Dootle",
  description: "Draw a character and generate AI stories",
  icons: {
    icon: '/DootlePencil.png',
    apple: '/DootlePencil.png',
  },
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <head>
        <link rel="icon" href="/DootlePencil.png" type="image/png" />
        <link rel="apple-touch-icon" href="/DootlePencil.png" />
        <link rel="shortcut icon" href="/DootlePencil.png" />
      </head>
      <body
        className={`${geistSans.variable} ${geistMono.variable} ${schoolbell.variable} antialiased`}
      >
        <AuthProvider>
          <TransitionProvider>
            <Clouds />
            {children}
          </TransitionProvider>
        </AuthProvider>
      </body>
    </html>
  );
}

```

### frontend/src/app/page.tsx

```typescript
'use client';

import { useState, useEffect } from 'react';
import NavBar from '@/components/NavBar';
import HomeSection from './sections/Home';
import DrawCharacter from './sections/DrawCharacter';
import DescribeCharacter from './sections/DescribeCharacter';
import Storybook from './sections/Storybook';
import Login from './sections/Login';
import Library from '@/components/Library';
import { useTransition } from '@/contexts/TransitionContext';
import { useAuth } from '@/contexts/AuthContext';

type Page = 'login' | 'landing' | 'drawing' | 'input' | 'storybook' | 'library';

export default function Home() {
  const { isLoggedIn, logout } = useAuth();
  const [currentPage, setCurrentPage] = useState<Page>(isLoggedIn ? 'landing' : 'login');
  const { startTransition, transitionPhase, setIsStorybookPage } = useTransition();

  // Redirect to login if not logged in
  useEffect(() => {
    if (!isLoggedIn && currentPage !== 'login') {
      setCurrentPage('login');
    }
  }, [isLoggedIn, currentPage]);

  // Determine fade class based on transition phase
  const getFadeClass = () => {
    if (transitionPhase === 'fading-out') return 'fade-out';
    if (transitionPhase === 'fading-in') return 'fade-in';
    return '';
  };

  const fadeClass = getFadeClass();

  const handleLogout = () => {
    logout();
    setCurrentPage('login');
  };

  const handleLibraryClick = () => {
    setIsStorybookPage(false);
    startTransition(() => setCurrentPage('library'));
  };

  // Render login page
  if (currentPage === 'login') {
    return <Login onLoginSuccess={() => setCurrentPage('landing')} />;
  }

  // Render library page
  if (currentPage === 'library') {
    return (
      <>
        <NavBar
          onHomeClick={() => {
            setIsStorybookPage(false);
            startTransition(() => setCurrentPage('landing'));
          }}
          onLibraryClick={handleLibraryClick}
          onLogoutClick={handleLogout}
        />
        <div className={fadeClass}>
          <Library onBackToStorybook={() => {
            setIsStorybookPage(true);
            startTransition(() => setCurrentPage('storybook'));
          }} />
        </div>
      </>
    );
  }

  // Render current page
  if (currentPage === 'landing') {
    return (
      <>
        <NavBar
          onHomeClick={() => startTransition(() => setCurrentPage('landing'))}
          onLibraryClick={handleLibraryClick}
          onLogoutClick={handleLogout}
        />
        <div className={fadeClass}>
          <HomeSection onStart={() => {
            setIsStorybookPage(false);
            startTransition(() => setCurrentPage('drawing'));
          }} />
        </div>
      </>
    );
  }

  if (currentPage === 'drawing') {
    return (
      <>
        <NavBar
          onHomeClick={() => {
            setIsStorybookPage(false);
            startTransition(() => setCurrentPage('landing'));
          }}
          onLibraryClick={handleLibraryClick}
          onLogoutClick={handleLogout}
        />
        <div className={fadeClass}>
          <DrawCharacter onNext={() => {
            setIsStorybookPage(false);
            startTransition(() => setCurrentPage('input'));
          }} />
        </div>
      </>
    );
  }

  if (currentPage === 'input') {
    return (
      <>
        <NavBar
          onHomeClick={() => {
            setIsStorybookPage(false);
            startTransition(() => setCurrentPage('landing'));
          }}
          onLibraryClick={handleLibraryClick}
          onLogoutClick={handleLogout}
        />
        <div className={fadeClass}>
          <DescribeCharacter
            onBack={() => {
              setIsStorybookPage(false);
              startTransition(() => setCurrentPage('drawing'));
            }}
            onStoryGenerated={() => {
              setIsStorybookPage(true);
              startTransition(() => setCurrentPage('storybook'));
            }}
          />
        </div>
      </>
    );
  }

  if (currentPage === 'storybook') {
    return (
      <>
        <NavBar
          onHomeClick={() => {
            setIsStorybookPage(false);
            startTransition(() => setCurrentPage('landing'));
          }}
          onLibraryClick={handleLibraryClick}
          onLogoutClick={handleLogout}
        />
        <div className={fadeClass}>
          <Storybook onBackToDrawing={() => {
            setIsStorybookPage(false);
            startTransition(() => setCurrentPage('drawing'));
          }} />
        </div>
      </>
    );
  }

  // Fallback
  return (
    <>
      <NavBar
        onHomeClick={() => {
          setIsStorybookPage(false);
          startTransition(() => setCurrentPage('landing'));
        }}
        onLibraryClick={handleLibraryClick}
        onLogoutClick={handleLogout}
      />
      <div className={fadeClass}>
        <HomeSection onStart={() => {
          setIsStorybookPage(false);
          startTransition(() => setCurrentPage('drawing'));
        }} />
      </div>
    </>
  );
}
```

### frontend/src/app/test/page.tsx

```typescript
'use client';

import { useState } from 'react';

// Test image
const TEST_IMAGE = 'data:image/png;base64,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';

interface StorySegment {
  type: 'text' | 'image';
  content: string;
}

interface Page {
  segments: StorySegment[];
}

interface StoryResponse {
  success: boolean;
  pages: Page[];
  char
[truncated — 7932 more characters]
```

### frontend/src/app/api/health/route.ts

```typescript
import { NextResponse } from 'next/server';

export async function GET() {
  return NextResponse.json({ 
    status: 'OK', 
    message: 'Character Story Generator API is running',
    timestamp: new Date().toISOString()
  });
}

```

### frontend/src/app/api/session/route.ts

```typescript
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';

// Types for the new backend response format
interface StorySegment {
  type: 'text' | 'image';
  content: string;
}

interface StoryPage {
  segments: StorySegment[];
}

interface StoryData {
  success: boolean;
  pages: StoryPage[];
  character: string;
  theme: string;
}

// In-memory session store (for demo - use Redis/DB in production)
const sessions = new Map<string, {
  imageData?: string;
  colorScheme?: string[];
  charDescription?: string;
  storyTheme?: string;
  storyData?: StoryData;
  createdAt: number;
  updatedAt: number;
}>();

// Clean up old sessions (older than 24 hours)
setInterval(() => {
  const now = Date.now();
  const oneDayMs = 24 * 60 * 60 * 1000;
  for (const [sessionId, session] of sessions.entries()) {
    if (now - session.updatedAt > oneDayMs) {
      sessions.delete(sessionId);
    }
  }
}, 60 * 60 * 1000); // Run every hour

async function getSessionId(request: NextRequest): Promise<string> {
  const cookieStore = await cookies();
  let sessionId = cookieStore.get('session_id')?.value;
  
  if (!sessionId) {
    sessionId = crypto.randomUUID();
  }
  
  return sessionId;
}

// GET - Retrieve session data
export async function GET(request: NextRequest) {
  const sessionId = await getSessionId(request);
  const sessionData = sessions.get(sessionId) || {
    createdAt: Date.now(),
    updatedAt: Date.now(),
  };

  const response = NextResponse.json(sessionData);
  response.cookies.set('session_id', sessionId, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 60 * 60 * 24, // 24 hours
  });

  return response;
}

// POST - Create/update session data
export async function POST(request: NextRequest) {
  const sessionId = await getSessionId(request);
  const body = await request.json();
  
  const existingData = sessions.get(sessionId) || {
    createdAt: Date.now(),
    updatedAt: Date.now(),
  };

  const updatedData = {
    ...existingData,
    ...body,
    updatedAt: Date.now(),
  };

  sessions.set(sessionId, updatedData);

  const response = NextResponse.json(updatedData);
  response.cookies.set('session_id', sessionId, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 60 * 60 * 24, // 24 hours
  });

  return response;
}

// DELETE - Clear session data
export async function DELETE(request: NextRequest) {
  const sessionId = await getSessionId(request);
  sessions.delete(sessionId);

  const response = NextResponse.json({ success: true });
  response.cookies.delete('session_id');

  return response;
}

```

### frontend/src/app/api/generate-story/route.ts

```typescript
import { NextRequest, NextResponse } from 'next/server';
import axios from 'axios';

export async function POST(request: NextRequest) {
  try {
    const { imageData, description } = await request.json();

    if (!imageData || !description) {
      return NextResponse.json(
        { error: 'Image data and description are required' },
        { status: 400 }
      );
    }

    const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;

    // Generate story using OpenRouter with Google Gemini
    if (OPENROUTER_API_KEY) {
      try {
        const prompt = `Create a children's storybook with 6-8 pages about a character based on this description: "${description.trim()}". 

Structure the story as follows:
- Page 1: Introduction of the character
- Page 2-3: The character's world and daily life
- Page 4-5: An adventure or challenge begins
- Page 6-7: The character faces obstacles and learns something important
- Page 8: A happy ending with a lesson learned

Each page should be 2-3 sentences long, suitable for children ages 4-8. Make it creative, engaging, and include elements of friendship, courage, or discovery.`;

        const response = await axios.post(
          'https://openrouter.ai/api/v1/chat/completions',
          {
            model: 'google/gemini-flash-1.5',
            messages: [
              {
                role: 'user',
                content: prompt
              }
            ],
            max_tokens: 1000,
            temperature: 0.7
          },
          {
            headers: {
              'Authorization': `Bearer ${OPENROUTER_API_KEY}`,
              'Content-Type': 'application/json',
              'HTTP-Referer': 'http://localhost:3000',
              'X-Title': 'Character Story Generator'
            }
          }
        );

        const story = response.data.choices[0]?.message?.content || 'Unable to generate story at this time.';
        
        // Parse the story into pages
        const pages = parseStoryIntoPages(story);
        
        return NextResponse.json({ 
          story: story,
          pages: pages,
          type: 'storybook'
        });
      } catch (error) {
        console.error('Error calling OpenRouter API:', error);
        // Fall back to mock story
      }
    }

    // Fallback mock story if API key is not configured
    const mockStory = `Page 1: Once upon a time, there was a wonderful character who embodied the spirit of "${description.trim()}". This character lived in a magical world full of possibilities.

Page 2: Every day, our character would explore their colorful world, meeting new friends and discovering amazing places. They loved to help others and always had a smile on their face.

Page 3: One sunny morning, our character noticed something unusual in their favorite garden. A small, lost creature was crying softly under a big tree.

Page 4: The character approached gently and asked, "Why are you so sad, little friend?" The creature explained they were lost and couldn't find their way home.

Page 5: Our brave character decided to help! They searched through forests, crossed streams, and climbed hills to find the creature's family.

Page 6: Along the way, they met many helpful animals who joined their quest. Together, they formed a wonderful team of friends.

Page 7: Finally, after many adventures, they found the creature's home! The family was overjoyed and thanked our character for their kindness.

Page 8: Our character learned that helping others brings the greatest joy. They returned home with new friends and wonderful memories, knowing that kindness makes the world a better place.`;

    const pages = parseStoryIntoPages(mockStory);
    
    return NextResponse.json({ 
      story: mockStory,
      pages: pages,
      type: 'storybook'
    });

  } catch (error) {
    console.error('Error generating story:', error);
    return NextResponse.json(
      { error: 'Failed to generate story' },
      { status: 500 }
    );
  }
}

function parseStoryIntoPages(story: string): string[] {
  const pageRegex = /Page \d+:/g;
  const pages = story.split(pageRegex).filter(page => page.trim().length > 0);
  return pages.map(page => page.trim()).filter(page => page.length > 0);
}

```

### start.sh

```shell
#!/bin/bash

echo "🎨 Character Story Generator"
echo "=========================="
echo ""

# Check if we're in the right directory
if [ ! -f "frontend/package.json" ]; then
    echo "❌ Please run this script from the dootle directory"
    exit 1
fi

# Kill any existing processes on port 3000 and 3001
echo "🧹 Cleaning up existing processes..."
lsof -ti:3000 | xargs kill -9 2>/dev/null || true
lsof -ti:3001 | xargs kill -9 2>/dev/null || true
sleep 2

# Check if node_modules exists
if [ ! -d "frontend/node_modules" ]; then
    echo "📦 Installing dependencies..."
    cd frontend
    npm install
    cd ..
else
    echo "📦 Checking for dependency updates..."
    cd frontend
    npm update
    cd ..
fi

# Check if .env.local exists
if [ ! -f "frontend/.env.local" ]; then
    echo "⚠️  Environment file not found!"
    echo "📝 Copying environment template..."
    cp frontend/env.example frontend/.env.local
    echo ""
    echo "🔑 Please edit frontend/.env.local and add your API keys:"
    echo "   - OPENROUTER_API_KEY (required for AI story generation)"
    echo "   - ELEVENLABS_API_KEY (optional for speech-to-text)"
    echo ""
    echo "Press Enter when ready to continue..."
    read
fi

echo "🚀 Starting the application..."
echo "📍 The app will be available at: http://localhost:3001"
echo ""

cd frontend
PORT=3001 npm run dev

```

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