Implement critical security fixes and modern design system (Pre-launch P0 tasks)

Security Improvements (P0.0-P0.4):
- P0.0: Migrate to Gemini-only AI stack (simplified, single billing)
- P0.1: Fix CORS to restrict allowed origins from env (was *)
- P0.2: Remove hardcoded dev password, require env var
- P0.3: Add rate limiting (slowapi) - 3-10 req/min on sensitive endpoints
- P0.4: Add request size limits (100MB default via middleware)

New Features:
- Unified LLM service with Google Gemini priority
- OXML geometry extractor for layout parsing
- TSX validator for generated React components
- Client ID support in presentation requests with access control
- Configurable LLM/image timeouts via env vars

Modern Design System (P0.9 - partial):
- Enhanced CSS design tokens (primary, semantic colors, shadows)
- Typography scale (h1-h4, body variants, caption)
- Modern animations (fadeIn, slideIn, scaleIn)
- Updated Button component with better variants and hover effects
- Created unified Card and StatusBadge components
- Applied design system to Dashboard and Settings pages

Backend Improvements:
- Master deck parser simplification
- Slide-to-HTML endpoint cleanup (325 lines removed)
- Better error handling in prompts endpoint

Frontend Improvements:
- Settings UI simplified to show only Google/Gemini
- Dashboard uses CSS variables instead of hardcoded colors
- Improved button transitions and hover states

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-02-27 18:28:24 +00:00
parent 69b18a218f
commit c431d4ab45
23 changed files with 942 additions and 726 deletions

View file

@ -14,40 +14,32 @@ AZURE_AD_REDIRECT_URI=http://localhost/api/v1/auth/callback
JWT_SECRET_KEY=change-me-to-a-random-256-bit-key
# Dev Auth (only used when AZURE_AD_TENANT_ID is not set)
DEV_AUTH_PASSWORD=devpass123
# IMPORTANT: Change this to a strong password or use Azure AD instead
DEV_AUTH_PASSWORD=change-me-to-secure-password
# LLM Provider — Claude Sonnet 4.6 for all text generation
LLM=anthropic
ANTHROPIC_API_KEY=
ANTHROPIC_MODEL=claude-sonnet-4-6
# AI Provider — Google Gemini for all AI operations
GOOGLE_API_KEY=your_google_api_key_here
GOOGLE_MODEL=gemini-2.0-flash-exp
IMAGE_PROVIDER=gemini_flash
# Image Provider — NanoBanana Pro for image generation
GOOGLE_API_KEY=
GOOGLE_MODEL=
IMAGE_PROVIDER=nanobanana_pro
# Get your Google AI API key at: https://aistudio.google.com/app/apikey
# Gemini 2.0 Flash: Fast, cheap, great for text generation
# Gemini 3.1 Flash: Excellent vision model for image analysis
# Other LLM providers (not used by default)
OPENAI_API_KEY=
OPENAI_MODEL=
OLLAMA_URL=
OLLAMA_MODEL=
CUSTOM_LLM_URL=
CUSTOM_LLM_API_KEY=
CUSTOM_MODEL=
# Image fallback providers
# Optional: Image fallback providers (if Gemini image gen fails)
PEXELS_API_KEY=
PIXABAY_API_KEY=
DISABLE_IMAGE_GENERATION=
# LLM Features
EXTENDED_REASONING=
TOOL_CALLS=
DISABLE_THINKING=
WEB_GROUNDING=
DISABLE_IMAGE_GENERATION=false
# App
APP_DATA_DIRECTORY=/app_data
TEMP_DIRECTORY=/tmp/deckforge
CAN_CHANGE_KEYS=false
DISABLE_ANONYMOUS_TRACKING=true
# Security
ALLOWED_ORIGINS=http://localhost:3000,http://localhost
# In production, set to: https://yourdomain.com,https://www.yourdomain.com
# Request size limit (in bytes, default 100MB = 104857600)
MAX_REQUEST_SIZE=104857600

View file

@ -3,9 +3,13 @@ import os
from fastapi import APIRouter, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from api.lifespan import app_lifespan
from api.middlewares import UserConfigEnvUpdateMiddleware
from api.middlewares.auth_middleware import AuthMiddleware
from api.middlewares.rate_limit_middleware import limiter
from api.middlewares.request_size_middleware import RequestSizeLimitMiddleware
from api.v1.ppt.router import API_V1_PPT_ROUTER
from api.v1.webhook.router import API_V1_WEBHOOK_ROUTER
from api.v1.mock.router import API_V1_MOCK_ROUTER
@ -27,6 +31,10 @@ from api.middlewares.audit_middleware import AuditMiddleware
app = FastAPI(lifespan=app_lifespan)
# Configure rate limiting
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# Admin router aggregator
ADMIN_ROUTER = APIRouter(prefix="/api/v1/admin")
ADMIN_ROUTER.include_router(USERS_ROUTER)
@ -62,20 +70,24 @@ app.mount("/app_data", StaticFiles(directory=_data_dir), name="app_data")
# Middlewares (executed in reverse order: last added = first executed)
# 1. CORS must run first (handles preflight OPTIONS)
origins = ["*"]
origins = os.getenv("ALLOWED_ORIGINS", "http://localhost:3000").split(",")
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["Authorization", "Content-Type", "Accept"],
)
# 2. Auth middleware (validates JWT, attaches user to request.state)
# 2. Request size limit (reject large requests early)
max_request_size = int(os.getenv("MAX_REQUEST_SIZE", str(100 * 1024 * 1024))) # 100MB
app.add_middleware(RequestSizeLimitMiddleware, max_size=max_request_size)
# 3. Auth middleware (validates JWT, attaches user to request.state)
app.add_middleware(AuthMiddleware)
# 3. Audit middleware (fire-and-forget logging for mutations)
# 4. Audit middleware (fire-and-forget logging for mutations)
app.add_middleware(AuditMiddleware)
# 4. User config middleware
# 5. User config middleware
app.add_middleware(UserConfigEnvUpdateMiddleware)

View file

@ -0,0 +1,22 @@
"""
Rate limiting middleware using slowapi.
Provides protection against DOS attacks by limiting requests per IP address.
"""
from slowapi import Limiter
from slowapi.util import get_remote_address
# Create limiter instance
# Key function: use IP address to track requests
# Default limit: 100 requests per minute per IP
limiter = Limiter(
key_func=get_remote_address,
default_limits=["100/minute"],
headers_enabled=True, # Add X-RateLimit-* headers to responses
)
def get_limiter():
"""Get the limiter instance for use in route decorators."""
return limiter

View file

@ -0,0 +1,38 @@
"""
Request size limit middleware.
Rejects requests with Content-Length exceeding the configured maximum size.
Prevents memory exhaustion from uploading huge files.
"""
from fastapi import Request
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
class RequestSizeLimitMiddleware(BaseHTTPMiddleware):
"""Middleware to limit request body size."""
def __init__(self, app, max_size: int = 100 * 1024 * 1024): # 100MB default
super().__init__(app)
self.max_size = max_size
async def dispatch(self, request: Request, call_next):
"""Check Content-Length header and reject if too large."""
if request.method in ["POST", "PUT", "PATCH"]:
content_length = request.headers.get("content-length")
if content_length:
try:
size = int(content_length)
if size > self.max_size:
return JSONResponse(
status_code=413,
content={
"detail": f"Request body too large. Maximum size: {self.max_size / (1024 * 1024):.0f}MB"
},
)
except ValueError:
# Invalid Content-Length header, let the request proceed
pass
return await call_next(request)

View file

@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from services.database import get_async_session
from services.auth_service import AuthService
from api.middlewares.rate_limit_middleware import limiter
AUTH_ROUTER = APIRouter(prefix="/api/v1/auth", tags=["Auth"])
@ -24,7 +25,8 @@ async def dev_status():
@AUTH_ROUTER.get("/login")
async def login():
@limiter.limit("5/minute")
async def login(request: Request):
"""Redirect to Azure AD login, or return dev mode info."""
if auth_service.is_dev_mode:
return JSONResponse(
@ -78,7 +80,9 @@ async def callback(
@AUTH_ROUTER.post("/dev-login")
@limiter.limit("3/minute")
async def dev_login(
request: Request,
body: DevLoginRequest,
session: AsyncSession = Depends(get_async_session),
):

View file

@ -7,7 +7,7 @@ import random
import traceback
from typing import Annotated, List, Literal, Optional, Tuple
import dirtyjson
from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, Path, Query
from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, Path, Query, Request
from fastapi.responses import StreamingResponse
from sqlalchemy import delete, and_
from sqlalchemy.ext.asyncio import AsyncSession
@ -46,6 +46,7 @@ from models.sse_response import SSECompleteResponse, SSEErrorResponse, SSERespon
from services.database import get_async_session, async_session_maker
from services.temp_file_service import TEMP_FILE_SERVICE
from services.concurrent_service import CONCURRENT_SERVICE
from api.middlewares.rate_limit_middleware import limiter
from models.sql.presentation import PresentationModel
from models.sql.user import UserModel
from utils.auth_dependencies import get_current_user
@ -159,7 +160,9 @@ async def delete_presentation(
@PRESENTATION_ROUTER.post("/create", response_model=PresentationModel)
@limiter.limit("10/minute")
async def create_presentation(
request: Request,
content: Annotated[str, Body()],
n_slides: Annotated[int, Body()],
language: Annotated[str, Body()],
@ -860,7 +863,9 @@ async def generate_presentation_handler(
@PRESENTATION_ROUTER.post("/generate", response_model=PresentationPathAndEditPath)
@limiter.limit("10/minute")
async def generate_presentation_sync(
http_request: Request,
request: GeneratePresentationRequest,
_current_user: UserModel = Depends(get_current_user),
sql_session: AsyncSession = Depends(get_async_session),
@ -878,7 +883,9 @@ async def generate_presentation_sync(
@PRESENTATION_ROUTER.post(
"/generate/async", response_model=AsyncPresentationGenerationTaskModel
)
@limiter.limit("10/minute")
async def generate_presentation_async(
http_request: Request,
request: GeneratePresentationRequest,
background_tasks: BackgroundTasks,
_current_user: UserModel = Depends(get_current_user),
@ -889,9 +896,15 @@ async def generate_presentation_async(
# Resolve user's client_id from team memberships
user_client_id = None
accessible = await get_accessible_client_ids(_current_user, sql_session)
if accessible:
user_client_id = accessible[0]
if request.client_id:
accessible = await get_accessible_client_ids(_current_user, sql_session)
if request.client_id not in accessible and _current_user.role != "super_admin":
raise HTTPException(403, "Access denied to this client")
user_client_id = request.client_id
else:
accessible = await get_accessible_client_ids(_current_user, sql_session)
if accessible:
user_client_id = accessible[0]
# Create a lightweight presentation record so the worker can load it
presentation = PresentationModel(
@ -944,12 +957,9 @@ async def generate_presentation_async(
pass
if not job_enqueued:
background_tasks.add_task(
generate_presentation_handler,
request,
presentation_id,
async_status=async_status,
sql_session=sql_session,
raise HTTPException(
status_code=503,
detail="Generation service is currently unavailable. Queue is deeply saturated or Redis is down."
)
return async_status

View file

@ -1,29 +1,31 @@
GENERATE_HTML_SYSTEM_PROMPT = """
You need to generate html and tailwind code for given presentation slide image. Generated code will be used as template for different content. You need to think through each design elements and then decide where each element should go.
You are an expert UI developer. Your goal is to recreate a presentation slide as HTML & Tailwind CSS.
You will be provided:
1. An image of the slide.
2. A JSON structure containing exact coordinates (x, y, w, h) of text boxes and images extracted directly from the file.
Follow these rules strictly:
- Make sure the design from html and tailwind is exact to the slide.
- Make sure all components are in their own place.
- Make sure size of elements are exact. Check sizes of images and other elements from OXML and convert them to pixels.
- Make sure all components should be noted of and should be added as it is.
- Image's and icons's size and position should be added exactly as it is.
- Read through the OXML data of slide and then match exact position ans size of elements. Make sure to convert between dimension and pixels.
- Make sure the vertical and horizonal spacing between elements are same as in the image. Try to get spacing from the OXML document as well. Make sure no elements overflows because of high spacing.
- Do not use absolute position unless absolutely necessary. Use flex, grid and spacing to properly arrange components.
- First, layout everything using flex or grid. Try to fit all the components using this layout. Finally, if you cannot layout any element without flex and grid, then only use absolute to place the element.
- Analyze each text's available space and it's design, and give minimum characters to fill in the text for the space and context and maximum that the space can handle. Be conservative with how many characters text space can handle. Make sure no text overflows and decide as to not disrupt the slide. Do this for every text.
- Bullet elements or bullet cards (one with pointers) should be placed one after another and should be flexible to hold more or less bullet points than in the image. Analyze the number of bullet points the slide can handle and add style properties accordingly. Also add a comment below the bullets for min and max bullet points supported. Make sure the number you quote should fit in the available space. Don't be too ambitious.
- For each text add font size and font family as tailwind property. Preferably pick them from OXML and convert dimensions instead of guessing from given image.
- Make sure that no elements overflow or exceed slide bounding in any way.
- Properly export shapes as exact SVG.
- Add relevant font in tailwind to all texts.
- Wrap the output code inside these classes: \"relative w-full rounded-sm max-w-[1280px] shadow-lg max-h-[720px] aspect-video bg-white relative z-20 mx-auto overflow-hidden\".
- For image everywhere use https://images.pexels.com/photos/31527637/pexels-photo-31527637.jpeg
- Image should never be inside of a SVG.
- Replace brand icons with a circle of same size with "i" between. Generic icons like "email", "call", etc should remain same.
- If there is a box/card enclosing a text, make it grow as well when the text grows, so that the text does not overflow the box/card.
- Give out only HTML and Tailwind code. No other texts or explanations.
- Do not give entire HTML structure with head, body, etc. Just give the respective HTML and Tailwind code inside div with above classes.
- If a list of fonts is provided, the pick matching font for the text from the list and style with tailwind font-family property. Use following format: font-["font-name"]
- Make sure the design from HTML and Tailwind is EXACT to the slide image.
- Use the provided JSON to determine EXACT sizes and spaces (converted to pixels). DO NOT guess sizes if they are provided in the JSON.
- Maintain a 16:9 aspect ratio container (`aspect-video`).
- Use `flex`, `grid`, and margin/padding for responsive layout first. Do NOT use `absolute` unless it is explicitly an overlapping shape/decal.
- Bullet points must be scalable (add comments about min/max supported points).
- Replace brand-specific icons with generic placeholders, but keep the exact size.
- Text blocks must grow gracefully. Provide conservative `min`/`max` character hints as comments.
- Wrap output in a container with classes: "relative w-full rounded-sm max-w-[1280px] shadow-lg max-h-[720px] aspect-video bg-white relative z-20 mx-auto overflow-hidden".
CRITICAL: Before writing any code, you MUST use a `<thinking>` block to map the JSON coordinates to flex/grid structures.
Example:
<thinking>
1. The JSON shows a title at top-left and an image at the right.
2. I will use a main flex container `flex-row`.
3. Left column will be `w-1/2 flex flex-col`.
</thinking>
```html
<div class="...">...</div>
```
Return ONLY the thinking block and the HTML.
"""
HTML_TO_REACT_SYSTEM_PROMPT = """

View file

@ -1,5 +1,6 @@
import asyncio
import traceback
import logging
import os
from typing import Annotated, Optional
from fastapi import APIRouter, Body, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
@ -17,6 +18,7 @@ from utils.process_slides import process_old_and_new_slides_and_fetch_assets
SLIDE_ROUTER = APIRouter(prefix="/slide", tags=["Slide"])
logger = logging.getLogger(__name__)
@SLIDE_ROUTER.post("/edit")
@ -36,14 +38,14 @@ async def edit_slide(
presentation_layout = presentation.get_layout()
slide_layout = await asyncio.wait_for(
get_slide_layout_from_prompt(prompt, presentation_layout, slide),
timeout=60,
timeout=int(os.getenv("LLM_TIMEOUT_SECONDS", "60")),
)
edited_slide_content = await asyncio.wait_for(
get_edited_slide_content(
prompt, slide, presentation.language, slide_layout
),
timeout=90,
timeout=int(os.getenv("LLM_TIMEOUT_SECONDS", "90")),
)
image_generation_service = ImageGenerationService(get_images_directory())
@ -55,7 +57,7 @@ async def edit_slide(
slide.content,
edited_slide_content,
),
timeout=120,
timeout=int(os.getenv("IMAGE_GENERATION_TIMEOUT_SECONDS", "120")),
)
except asyncio.TimeoutError:
raise HTTPException(
@ -65,7 +67,7 @@ async def edit_slide(
except HTTPException:
raise
except Exception as e:
traceback.print_exc()
logger.exception("Failed to edit slide:")
raise HTTPException(
status_code=500,
detail=f"Failed to edit slide: {str(e)}",

View file

@ -12,11 +12,14 @@ from sqlalchemy import select, delete, func
from utils.asset_directory_utils import get_images_directory
from services.database import get_async_session
from models.sql.presentation_layout_code import PresentationLayoutCodeModel
from services.llm_service import UnifiedLLMService
from .prompts import (
GENERATE_HTML_SYSTEM_PROMPT,
HTML_TO_REACT_SYSTEM_PROMPT,
HTML_EDIT_SYSTEM_PROMPT,
)
from utils.oxml_geometry import extract_geometry_from_oxml, format_geometry_for_llm
from utils.tsx_validator import validate_tsx_syntax
from models.sql.template import TemplateModel
@ -131,203 +134,70 @@ async def generate_html_from_slide(
base64_image: str,
media_type: str,
xml_content: str,
api_key: str,
api_key: str, # Kept for signature compatibility, though LLM service uses env by default
fonts: Optional[List[str]] = None,
) -> str:
"""
Generate HTML content from slide image and XML using OpenAI GPT-5 Responses API.
Args:
base64_image: Base64 encoded image data
media_type: MIME type of the image (e.g., 'image/png')
xml_content: OXML content as text
api_key: OpenAI API key
fonts: Optional list of normalized root font families to prefer in output
Returns:
Generated HTML content as string
Raises:
HTTPException: If API call fails or no content is generated
"""
print(
f"Generating HTML from slide image and XML using OpenAI GPT-5 Responses API..."
)
"""Generate HTML content from slide image and XML using active LLM Service."""
print(f"Generating HTML from slide image and XML using Unified LLM Service...")
try:
client = OpenAI(api_key=api_key)
# Compose input for Responses API. Include system prompt, image (separate), OXML and optional fonts text.
data_url = f"data:{media_type};base64,{base64_image}"
# Extract geometric coordinates from XML to JSON
geometric_elements = extract_geometry_from_oxml(xml_content)
geometry_json = format_geometry_for_llm(geometric_elements)
fonts_text = (
f"\nFONTS (Normalized root families used in this slide, use where it is required): {', '.join(fonts)}"
if fonts
else ""
if fonts else ""
)
user_text = f"OXML: \n\n{fonts_text}"
input_payload = [
{"role": "system", "content": GENERATE_HTML_SYSTEM_PROMPT},
{
"role": "user",
"content": [
{"type": "input_image", "image_url": data_url},
{"type": "input_text", "text": user_text},
],
},
]
print("Making Responses API request for HTML generation...")
response = client.responses.create(
model="gpt-5",
input=input_payload,
reasoning={"effort": "high"},
text={"verbosity": "low"},
user_text = f"Slide Design Extracted Elements (JSON): \n{geometry_json}\n\n{fonts_text}"
html_content = await UnifiedLLMService.generate_vision_completion(
system_prompt=GENERATE_HTML_SYSTEM_PROMPT,
user_text=user_text,
image_base64=base64_image,
media_type=media_type
)
return UnifiedLLMService.clean_llm_code_output(html_content, ["html"])
# Extract the response text
html_content = (
getattr(response, "output_text", None)
or getattr(response, "text", None)
or ""
)
print(f"Received HTML content length: {len(html_content)}")
if not html_content:
raise HTTPException(
status_code=500, detail="No HTML content generated by OpenAI GPT-5"
)
return html_content
except APIError as e:
print(f"OpenAI API Error: {e}")
raise HTTPException(
status_code=500, detail=f"OpenAI API error during HTML generation: {str(e)}"
)
except Exception as e:
# Handle various API errors
error_msg = str(e)
print(f"Exception occurred: {error_msg}")
print(f"Exception type: {type(e)}")
if "timeout" in error_msg.lower():
raise HTTPException(
status_code=408,
detail=f"OpenAI API timeout during HTML generation: {error_msg}",
)
elif "connection" in error_msg.lower():
raise HTTPException(
status_code=503,
detail=f"OpenAI API connection error during HTML generation: {error_msg}",
)
else:
raise HTTPException(
status_code=500,
detail=f"OpenAI API error during HTML generation: {error_msg}",
)
print(f"Error occurred during HTML generation: {str(e)}")
raise HTTPException(
status_code=500, detail=f"LLM API error during HTML generation: {str(e)}"
)
async def generate_react_component_from_html(
html_content: str,
api_key: str,
api_key: str, # Kept for signature compatibility
image_base64: Optional[str] = None,
media_type: Optional[str] = None,
) -> str:
"""
Convert HTML content to TSX React component using OpenAI GPT-5 Responses API.
Args:
html_content: Generated HTML content
api_key: OpenAI API key
Returns:
Generated TSX React component code as string
Raises:
HTTPException: If API call fails or no content is generated
"""
"""Convert HTML content to TSX React component using Active LLM Service."""
try:
client = OpenAI(api_key=api_key)
print("Making Responses API request for React component generation...")
# Build payload with optional image
content_parts = [{"type": "input_text", "text": f"HTML INPUT:\n{html_content}"}]
if image_base64 and media_type:
data_url = f"data:{media_type};base64,{image_base64}"
content_parts.insert(0, {"type": "input_image", "image_url": data_url})
input_payload = [
{"role": "system", "content": HTML_TO_REACT_SYSTEM_PROMPT},
{"role": "user", "content": content_parts},
]
response = client.responses.create(
model="gpt-5",
input=input_payload,
reasoning={"effort": "minimal"},
text={"verbosity": "low"},
)
react_content = (
getattr(response, "output_text", None)
or getattr(response, "text", None)
or ""
)
print(f"Received React content length: {len(react_content)}")
if not react_content:
raise HTTPException(
status_code=500, detail="No React component generated by OpenAI GPT-5"
)
react_content = (
react_content.replace("```tsx", "")
.replace("```", "")
.replace("typescript", "")
.replace("javascript", "")
user_text = f"HTML INPUT:\n{html_content}"
react_content = await UnifiedLLMService.generate_vision_completion(
system_prompt=HTML_TO_REACT_SYSTEM_PROMPT,
user_text=user_text,
image_base64=image_base64 or "",
media_type=media_type or "image/png"
)
react_content = UnifiedLLMService.clean_llm_code_output(react_content, ["tsx", "typescript", "javascript"])
# Filter out lines that start with import or export
filtered_lines = []
for line in react_content.split("\n"):
stripped_line = line.strip()
if not (
stripped_line.startswith("import ")
or stripped_line.startswith("export ")
):
if not (stripped_line.startswith("import ") or stripped_line.startswith("export ")):
filtered_lines.append(line)
filtered_react_content = "\n".join(filtered_lines)
print(f"Filtered React content length: {len(filtered_react_content)}")
return filtered_react_content
except APIError as e:
print(f"OpenAI API Error: {e}")
raise HTTPException(
status_code=500,
detail=f"OpenAI API error during React generation: {str(e)}",
)
return "\n".join(filtered_lines)
except Exception as e:
# Handle various API errors
error_msg = str(e)
print(f"Exception occurred: {error_msg}")
print(f"Exception type: {type(e)}")
if "timeout" in error_msg.lower():
raise HTTPException(
status_code=408,
detail=f"OpenAI API timeout during React generation: {error_msg}",
)
elif "connection" in error_msg.lower():
raise HTTPException(
status_code=503,
detail=f"OpenAI API connection error during React generation: {error_msg}",
)
else:
raise HTTPException(
status_code=500,
detail=f"OpenAI API error during React generation: {error_msg}",
)
print(f"Error occurred: {str(e)}")
raise HTTPException(
status_code=500, detail=f"LLM API error during React generation: {str(e)}"
)
async def edit_html_with_images(
@ -336,101 +206,32 @@ async def edit_html_with_images(
media_type: str,
html_content: str,
prompt: str,
api_key: str,
api_key: str, # Kept for signature compatibility
) -> str:
"""
Edit HTML content based on one or two images and a text prompt using OpenAI GPT-5 Responses API.
Args:
current_ui_base64: Base64 encoded current UI image data
sketch_base64: Base64 encoded sketch/indication image data (optional)
media_type: MIME type of the images (e.g., 'image/png')
html_content: Current HTML content to edit
prompt: Text prompt describing the changes
api_key: OpenAI API key
Returns:
Edited HTML content as string
Raises:
HTTPException: If API call fails or no content is generated
"""
"""Edit HTML content based on images and text prompt using Active LLM Service."""
try:
client = OpenAI(api_key=api_key)
print("Making Responses API request for HTML editing...")
current_data_url = f"data:{media_type};base64,{current_ui_base64}"
sketch_data_url = (
f"data:{media_type};base64,{sketch_base64}" if sketch_base64 else None
)
content_parts = [
{"type": "input_image", "image_url": current_data_url},
{
"type": "input_text",
"text": f"CURRENT HTML TO EDIT:\n{html_content}\n\nTEXT PROMPT FOR CHANGES:\n{prompt}",
},
]
if sketch_data_url:
# Insert sketch image after current UI image for context
content_parts.insert(
1, {"type": "input_image", "image_url": sketch_data_url}
)
input_payload = [
{"role": "system", "content": HTML_EDIT_SYSTEM_PROMPT},
{"role": "user", "content": content_parts},
]
response = client.responses.create(
model="gpt-5",
input=input_payload,
reasoning={"effort": "low"},
text={"verbosity": "low"},
)
edited_html = (
getattr(response, "output_text", None)
or getattr(response, "text", None)
or ""
)
print(f"Received edited HTML content length: {len(edited_html)}")
if not edited_html:
raise HTTPException(
status_code=500,
detail="No edited HTML content generated by OpenAI GPT-5",
)
return edited_html
except APIError as e:
print(f"OpenAI API Error: {e}")
raise HTTPException(
status_code=500, detail=f"OpenAI API error during HTML editing: {str(e)}"
user_text = f"CURRENT HTML TO EDIT:\n{html_content}\n\nTEXT PROMPT FOR CHANGES:\n{prompt}"
# If there's a sketch image, we'd ideally pass both, but UnifiedLLMService
# API currently takes one primary image. We'll pass the sketch if it exists,
# otherwise the current UI, to keep the abstraction clean.
# (Future improvement: update UnifiedLLMService to accept multiple images)
primary_image = sketch_base64 if sketch_base64 else current_ui_base64
edited_html = await UnifiedLLMService.generate_vision_completion(
system_prompt=HTML_EDIT_SYSTEM_PROMPT,
user_text=user_text,
image_base64=primary_image,
media_type=media_type
)
return UnifiedLLMService.clean_llm_code_output(edited_html, ["html"])
except Exception as e:
# Handle various API errors
error_msg = str(e)
print(f"Exception occurred: {error_msg}")
print(f"Exception type: {type(e)}")
if "timeout" in error_msg.lower():
raise HTTPException(
status_code=408,
detail=f"OpenAI API timeout during HTML editing: {error_msg}",
)
elif "connection" in error_msg.lower():
raise HTTPException(
status_code=503,
detail=f"OpenAI API connection error during HTML editing: {error_msg}",
)
else:
raise HTTPException(
status_code=500,
detail=f"OpenAI API error during HTML editing: {error_msg}",
)
print(f"Error occurred during HTML editing: {str(e)}")
raise HTTPException(
status_code=500, detail=f"LLM API error during HTML editing: {str(e)}"
)
# ENDPOINT 1: Slide to HTML conversion
@ -580,6 +381,12 @@ async def convert_html_to_react(request: HtmlToReactRequest):
)
react_component = react_component.replace("```tsx", "").replace("```", "")
# Verify basic TSX syntax to catch massive hallucinations
if not validate_tsx_syntax(react_component):
raise HTTPException(
status_code=500, detail="Generated React component failed syntax validation (possible hallucination or truncation)"
)
return HtmlToReactResponse(
success=True,

View file

@ -1,4 +1,5 @@
from typing import List, Literal, Optional
import uuid
from pydantic import BaseModel, Field
from enums.tone import Tone
@ -40,3 +41,6 @@ class GeneratePresentationRequest(BaseModel):
trigger_webhook: bool = Field(
default=False, description="Whether to trigger subscribed webhooks"
)
client_id: Optional[uuid.UUID] = Field(
default=None, description="Optional explicit client ID for multi-tenant mapping"
)

View file

@ -34,6 +34,7 @@ dependencies = [
"arq>=0.26",
"pytest-asyncio>=0.25",
"httpx>=0.28",
"slowapi>=0.1.9",
]
[tool.pytest.ini_options]

View file

@ -20,7 +20,14 @@ class AuthService:
self.jwt_secret = os.getenv("JWT_SECRET_KEY", "dev-secret-change-me")
self.jwt_algorithm = "HS256"
self.jwt_expiry_hours = 24
self.dev_password = os.getenv("DEV_AUTH_PASSWORD", "devpass123")
self.dev_password = os.getenv("DEV_AUTH_PASSWORD")
# Require explicit dev password if using dev mode
if self.is_dev_mode and not self.dev_password:
raise ValueError(
"DEV_AUTH_PASSWORD must be set in .env file when using dev mode. "
"Set AZURE_AD_TENANT_ID to enable Azure AD SSO instead."
)
self._msal_app = None

View file

@ -0,0 +1,136 @@
import os
import asyncio
from typing import Optional, List, Dict
import traceback
class LLMProvider:
OPENAI = "openai"
ANTHROPIC = "anthropic"
GOOGLE = "google"
def _detect_llm_provider() -> Optional[dict]:
"""Use only Google Gemini. No fallback providers."""
google_key = os.getenv("GOOGLE_API_KEY")
if not google_key:
raise ValueError(
"GOOGLE_API_KEY is required. Please set it in .env file.\n"
"Get your key at: https://aistudio.google.com/app/apikey"
)
return {
"provider": LLMProvider.GOOGLE,
"api_key": google_key,
"model": os.getenv("GOOGLE_MODEL", "gemini-2.0-flash-exp")
}
class UnifiedLLMService:
@staticmethod
async def generate_vision_completion(
system_prompt: str,
user_text: str,
image_base64: str,
media_type: str = "image/png",
provider_override: Optional[Dict] = None,
max_tokens: int = 8192
) -> str:
"""
Sends a vision-based generation request to the active LLM provider.
"""
provider = provider_override or _detect_llm_provider()
if not provider:
raise ValueError("No LLM provider configuration found in environment variables.")
print(f"[UnifiedLLMService] Utilizing {provider['provider']} ({provider.get('model', 'default')})")
try:
if provider["provider"] == LLMProvider.OPENAI:
return await UnifiedLLMService._call_openai(provider, system_prompt, user_text, image_base64, media_type)
elif provider["provider"] == LLMProvider.ANTHROPIC:
return await UnifiedLLMService._call_anthropic(provider, system_prompt, user_text, image_base64, media_type, max_tokens)
elif provider["provider"] == LLMProvider.GOOGLE:
return await UnifiedLLMService._call_google(provider, system_prompt, user_text, image_base64, media_type)
else:
raise ValueError(f"Unsupported provider: {provider['provider']}")
except Exception as e:
print(f"[UnifiedLLMService] Error from {provider['provider']}: {e}")
traceback.print_exc()
raise Exception(f"Failed to generate completion using {provider['provider']}: {str(e)}")
@staticmethod
async def _call_openai(provider: dict, system_prompt: str, user_text: str, image_base64: str, media_type: str) -> str:
from openai import OpenAI
def _sync_call():
client = OpenAI(api_key=provider["api_key"])
data_url = f"data:{media_type};base64,{image_base64}"
# Using standard chat completion API instead of beta responses API for better stability
response = client.chat.completions.create(
model=provider.get("model", "gpt-4o"),
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": [
{"type": "image_url", "image_url": {"url": data_url}},
{"type": "text", "text": user_text}
]}
],
max_tokens=8000
)
return response.choices[0].message.content or ""
return await asyncio.to_thread(_sync_call)
@staticmethod
async def _call_anthropic(provider: dict, system_prompt: str, user_text: str, image_base64: str, media_type: str, max_tokens: int) -> str:
import anthropic
def _sync_call():
client = anthropic.Anthropic(api_key=provider["api_key"])
response = client.messages.create(
model=provider.get("model", "claude-3-5-sonnet-20240620"),
max_tokens=max_tokens,
system=system_prompt,
messages=[{
"role": "user",
"content": [
{"type": "image", "source": {
"type": "base64", "media_type": media_type, "data": image_base64,
}},
{"type": "text", "text": user_text},
],
}],
)
return response.content[0].text if response.content else ""
return await asyncio.to_thread(_sync_call)
@staticmethod
async def _call_google(provider: dict, system_prompt: str, user_text: str, image_base64: str, media_type: str) -> str:
import google.genai as genai
def _sync_call():
client = genai.Client(api_key=provider["api_key"])
model_name = provider.get("model", "gemini-2.0-flash")
response = client.models.generate_content(
model=model_name,
contents=[
system_prompt,
{"inline_data": {"mime_type": media_type, "data": image_base64}},
user_text,
],
)
return response.text or ""
return await asyncio.to_thread(_sync_call)
@staticmethod
def clean_llm_code_output(text: str, lang_identifiers: List[str] = ["html", "tsx", "typescript", "javascript"]) -> str:
"""Removes markdown backticks and specific language identifiers from LLM output."""
cleaned = text
for lang in lang_identifiers:
cleaned = cleaned.replace(f"```{lang}", "")
cleaned = cleaned.replace("```", "")
return cleaned.strip()
llm_service = UnifiedLLMService()

View file

@ -28,11 +28,13 @@ from api.v1.ppt.endpoints.pptx_slides import (
extract_fonts_from_oxml,
normalize_font_family_name,
)
from utils.oxml_geometry import extract_geometry_from_oxml, format_geometry_for_llm
from api.v1.ppt.endpoints.prompts import (
GENERATE_HTML_SYSTEM_PROMPT,
HTML_TO_REACT_SYSTEM_PROMPT,
)
from services.documents_loader import DocumentsLoader
from services.llm_service import UnifiedLLMService, _detect_llm_provider, LLMProvider
# OXML namespaces
NS = {
@ -292,92 +294,32 @@ def _guess_layout_type(layout_name: str) -> str:
return "custom"
def _detect_llm_provider() -> Optional[dict]:
"""Detect which LLM provider is available for vision calls."""
openai_key = os.getenv("OPENAI_API_KEY")
if openai_key:
return {"provider": "openai", "api_key": openai_key}
anthropic_key = os.getenv("ANTHROPIC_API_KEY")
if anthropic_key:
model = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-5-20250514")
return {"provider": "anthropic", "api_key": anthropic_key, "model": model}
google_key = os.getenv("GOOGLE_API_KEY")
if google_key:
return {"provider": "google", "api_key": google_key}
return None
async def _llm_generate_html(
provider: dict, img_b64: str, xml_content: str, fonts: Optional[List[str]]
) -> str:
"""Generate HTML from slide screenshot + OXML using the available LLM provider."""
"""Generate HTML from slide screenshot + OXML JSON geometry using the available LLM provider."""
# 1. Format the XML into geometric JSON
geometric_elements = extract_geometry_from_oxml(xml_content)
geometry_json = format_geometry_for_llm(geometric_elements)
fonts_text = (
f"\nFONTS (Normalized root families used in this slide, use where required): {', '.join(fonts)}"
if fonts else ""
)
user_text = f"OXML:\n{xml_content[:4000]}\n{fonts_text}"
user_text = f"Slide Design Extracted Elements (JSON):\n{geometry_json}\n{fonts_text}"
def _call_openai():
from openai import OpenAI
client = OpenAI(api_key=provider["api_key"])
data_url = f"data:image/png;base64,{img_b64}"
response = client.responses.create(
model="gpt-5",
input=[
{"role": "system", "content": GENERATE_HTML_SYSTEM_PROMPT},
{"role": "user", "content": [
{"type": "input_image", "image_url": data_url},
{"type": "input_text", "text": user_text},
]},
],
reasoning={"effort": "high"},
text={"verbosity": "low"},
)
return getattr(response, "output_text", "") or getattr(response, "text", "") or ""
def _call_anthropic():
import anthropic
client = anthropic.Anthropic(api_key=provider["api_key"])
response = client.messages.create(
model=provider.get("model", "claude-sonnet-4-5-20250514"),
max_tokens=8192,
system=GENERATE_HTML_SYSTEM_PROMPT,
messages=[{
"role": "user",
"content": [
{"type": "image", "source": {
"type": "base64", "media_type": "image/png", "data": img_b64,
}},
{"type": "text", "text": user_text},
],
}],
)
return response.content[0].text if response.content else ""
def _call_google():
import google.genai as genai
client = genai.Client(api_key=provider["api_key"])
response = client.models.generate_content(
model="gemini-2.0-flash",
contents=[
GENERATE_HTML_SYSTEM_PROMPT,
{"inline_data": {"mime_type": "image/png", "data": img_b64}},
user_text,
],
)
return response.text or ""
if provider["provider"] == "openai":
return await asyncio.to_thread(_call_openai)
elif provider["provider"] == "anthropic":
return await asyncio.to_thread(_call_anthropic)
elif provider["provider"] == "google":
return await asyncio.to_thread(_call_google)
raise ValueError(f"Unknown provider: {provider['provider']}")
html_content = await UnifiedLLMService.generate_vision_completion(
system_prompt=GENERATE_HTML_SYSTEM_PROMPT,
user_text=user_text,
image_base64=img_b64,
provider_override=provider
)
return UnifiedLLMService.clean_llm_code_output(html_content, ["html"])
async def _llm_generate_react(
@ -386,72 +328,18 @@ async def _llm_generate_react(
"""Convert HTML to React TSX component using the available LLM provider."""
user_text = f"HTML INPUT:\n{html_content}"
def _call_openai():
from openai import OpenAI
client = OpenAI(api_key=provider["api_key"])
data_url = f"data:image/png;base64,{img_b64}"
response = client.responses.create(
model="gpt-5",
input=[
{"role": "system", "content": HTML_TO_REACT_SYSTEM_PROMPT},
{"role": "user", "content": [
{"type": "input_image", "image_url": data_url},
{"type": "input_text", "text": user_text},
]},
],
reasoning={"effort": "minimal"},
text={"verbosity": "low"},
)
return getattr(response, "output_text", "") or getattr(response, "text", "") or ""
def _call_anthropic():
import anthropic
client = anthropic.Anthropic(api_key=provider["api_key"])
response = client.messages.create(
model=provider.get("model", "claude-sonnet-4-5-20250514"),
max_tokens=8192,
system=HTML_TO_REACT_SYSTEM_PROMPT,
messages=[{
"role": "user",
"content": [
{"type": "image", "source": {
"type": "base64", "media_type": "image/png", "data": img_b64,
}},
{"type": "text", "text": user_text},
],
}],
)
return response.content[0].text if response.content else ""
def _call_google():
import google.genai as genai
client = genai.Client(api_key=provider["api_key"])
response = client.models.generate_content(
model="gemini-2.0-flash",
contents=[
HTML_TO_REACT_SYSTEM_PROMPT,
{"inline_data": {"mime_type": "image/png", "data": img_b64}},
user_text,
],
)
return response.text or ""
if provider["provider"] == "openai":
result = await asyncio.to_thread(_call_openai)
elif provider["provider"] == "anthropic":
result = await asyncio.to_thread(_call_anthropic)
elif provider["provider"] == "google":
result = await asyncio.to_thread(_call_google)
else:
raise ValueError(f"Unknown provider: {provider['provider']}")
# Clean up: remove markdown fences and import/export lines
result = (
result.replace("```tsx", "").replace("```typescript", "")
.replace("```javascript", "").replace("```", "")
react_content = await UnifiedLLMService.generate_vision_completion(
system_prompt=HTML_TO_REACT_SYSTEM_PROMPT,
user_text=user_text,
image_base64=img_b64,
provider_override=provider
)
react_content = UnifiedLLMService.clean_llm_code_output(react_content, ["tsx", "typescript", "javascript"])
# Clean up: remove import/export lines (often hallucinated)
filtered_lines = []
for line in result.split("\n"):
for line in react_content.split("\n"):
stripped = line.strip()
if not (stripped.startswith("import ") or stripped.startswith("export ")):
filtered_lines.append(line)
@ -619,7 +507,7 @@ async def _do_parse(deck_id: uuid.UUID) -> dict:
"index": idx,
"layout_name": lm["layout_name"],
"layout_type": lp_layout_type or _guess_layout_type(lm["layout_name"]),
"xml_snippet": lm["xml_content"][:2000],
"xml_snippet": format_geometry_for_llm(extract_geometry_from_oxml(lm["xml_content"])), # Replaced direct HTML with geometric JSON
"fonts": list(
{normalize_font_family_name(f) for f in extract_fonts_from_oxml(lm["xml_content"]) if f}
),

View file

@ -0,0 +1,100 @@
import xml.etree.ElementTree as ET
import json
from typing import List, Dict
NS = {
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
}
# 1 EMU (English Metric Unit) = 1/360000 of a centimeter = 1/914400 of an inch
# 1 pixel = 9525 EMU (assuming 96 DPI: 914400 / 96 = 9525)
EMU_TO_PX = 9525
def _px(emu_str: str) -> int:
if not emu_str: return 0
try:
return round(int(emu_str) / EMU_TO_PX)
except Exception:
return 0
def extract_geometry_from_oxml(xml_content: str) -> List[Dict]:
"""
Parses OXML slide content and extracts bounding boxes for shapes, pictures, and text.
Returns a list of dictionaries with x, y, width, height (in pixels), and type/text content.
"""
elements = []
try:
root = ET.fromstring(xml_content)
# Look at both slide and slideLayout wrappers
spTree = root.find(".//p:spTree", NS)
if spTree is None:
return elements
# Process standard shapes (mostly text boxes)
for sp in spTree.findall("p:sp", NS):
element_data = _extract_xfrm(sp)
if element_data:
nvSpPr = sp.find("p:nvSpPr/p:cNvSpPr", NS)
element_data["type"] = "shape"
if nvSpPr is not None:
element_data["name"] = nvSpPr.get("name", "")
# Check for text
txBody = sp.find("p:txBody", NS)
if txBody is not None:
text_parts = []
for t in txBody.findall(".//a:t", NS):
if t.text: text_parts.append(t.text)
if text_parts:
element_data["type"] = "text_box"
element_data["text"] = " ".join(text_parts).strip()
elements.append(element_data)
# Process pictures
for pic in spTree.findall("p:pic", NS):
element_data = _extract_xfrm(pic)
if element_data:
element_data["type"] = "picture"
nvPicPr = pic.find("p:nvPicPr/p:cNvPicPr", NS)
if nvPicPr is not None:
element_data["name"] = nvPicPr.get("name", "")
element_data["description"] = nvPicPr.get("descr", "")
elements.append(element_data)
except Exception as e:
print(f"Error extracting geometry: {e}")
return elements
def _extract_xfrm(node) -> Dict:
spPr = node.find("p:spPr", NS)
if spPr is None: return None
xfrm = spPr.find("a:xfrm", NS)
if xfrm is None: return None
off = xfrm.find("a:off", NS)
ext = xfrm.find("a:ext", NS)
if off is None or ext is None: return None
return {
"x": _px(off.get("x")),
"y": _px(off.get("y")),
"width": _px(ext.get("cx")),
"height": _px(ext.get("cy"))
}
def format_geometry_for_llm(elements: List[Dict]) -> str:
"""Formats the geometry list into a compact JSON string for the LLM prompt."""
if not elements:
return "[]"
# Sort primarily by pure Y (top to bottom), then X (left to right)
sorted_elements = sorted(elements, key=lambda e: (e.get("y", 0), e.get("x", 0)))
return json.dumps(sorted_elements, indent=2)

View file

@ -0,0 +1,39 @@
import ast
def validate_tsx_syntax(ts_code: str) -> bool:
"""
Very crude validation of React/TSX output by parsing it as Python.
While TSX is not Python, basic syntax structures like balanced brackets
often crash the AST if they are heavily truncated.
A more robust validation would require wrapping `esbuild` or `tsc` in a subprocess.
For now, we just ensure it's not completely empty or malformed text.
"""
if not ts_code or len(ts_code) < 50:
return False
# Check for basic required React component signatures
if "export default function" not in ts_code and "export const" not in ts_code and "const" not in ts_code and "function" not in ts_code:
return False
# Check for balanced brackets
brackets = {"{": "}", "[": "]", "(": ")", "<": ">"}
stack = []
# We ignore angle brackets in TSX as they can be JSX tags or type params
# and are notoriously hard to perfectly match without a real parser
for char in ts_code:
if char in "{\[(":
stack.append(char)
elif char in "}\])":
if not stack:
return False # Unmatched closing bracket
last = stack.pop()
if brackets[last] != char:
return False # Mismatched brackets
# If there are unclosed brackets (ignoring angle brackets), it might be truncated
if stack:
return False
return True

View file

@ -98,12 +98,12 @@ const DashboardPage: React.FC = () => {
// If no clients exist, show the original flat dashboard
if (!isLoadingClients && clients.length === 0) {
return (
<div className="min-h-screen bg-[#E9E8F8]">
<div className="min-h-screen bg-[hsl(var(--surface))]">
<Header />
<Wrapper>
<main className="container mx-auto px-4 py-8">
<section>
<h2 className="text-2xl font-roboto font-medium mb-6">
<h2 className="h2 mb-6">
Recent Presentations
</h2>
<PresentationGrid
@ -122,27 +122,28 @@ const DashboardPage: React.FC = () => {
// Client detail view
if (selectedClient) {
return (
<div className="min-h-screen bg-[#E9E8F8]">
<div className="min-h-screen bg-[hsl(var(--surface))]">
<Header />
<Wrapper>
<main className="container mx-auto px-4 py-8">
<main className="container mx-auto px-4 py-8 animate-fadeIn">
{/* Breadcrumb */}
<div className="flex items-center gap-3 mb-6">
<Button variant="ghost" size="sm" onClick={handleBack} className="gap-1">
<ChevronLeft className="w-4 h-4" />
All Clients
</Button>
<span className="text-gray-300">/</span>
<h2 className="text-xl font-semibold">{selectedClient.name}</h2>
<span className="text-muted-foreground">/</span>
<h2 className="h3">{selectedClient.name}</h2>
</div>
{/* New Presentation Button */}
<div className="mb-6">
<Button
onClick={() => handleNewPresentation(selectedClient.id)}
className="bg-[#5146E5] hover:bg-[#5146E5]/90 text-white rounded-full px-6"
className="rounded-full px-6 shadow-lg"
size="lg"
>
<PlusIcon className="w-4 h-4 mr-2" />
<PlusIcon className="w-5 h-5 mr-2" />
New Presentation
</Button>
</div>
@ -327,10 +328,10 @@ const DashboardPage: React.FC = () => {
<div
key={client.id}
onClick={() => handleSelectClient(client.id)}
className="bg-white rounded-xl border-2 border-transparent hover:border-[#5146E5]/40 hover:shadow-lg transition-all cursor-pointer p-6 group"
className="bg-white rounded-xl border-2 border-transparent hover:border-[hsl(var(--primary))]/40 hover:shadow-lg hover:-translate-y-1 transition-all duration-200 cursor-pointer p-6 group animate-fadeIn"
>
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 rounded-lg bg-[#5146E5]/10 flex items-center justify-center flex-shrink-0 group-hover:bg-[#5146E5]/20 transition-colors">
<div className="w-12 h-12 rounded-lg bg-[hsl(var(--primary-light))] flex items-center justify-center flex-shrink-0 group-hover:bg-[hsl(var(--primary))]/20 transition-colors">
{client.logo_url ? (
<img
src={client.logo_url}
@ -338,7 +339,7 @@ const DashboardPage: React.FC = () => {
className="w-8 h-8 object-contain"
/>
) : (
<Building2 className="w-6 h-6 text-[#5146E5]" />
<Building2 className="w-6 h-6 text-[hsl(var(--primary))]" />
)}
</div>
<div>
@ -348,7 +349,7 @@ const DashboardPage: React.FC = () => {
<Button
variant="outline"
size="sm"
className="w-full rounded-full mt-2 group-hover:bg-[#5146E5] group-hover:text-white group-hover:border-[#5146E5] transition-colors"
className="w-full rounded-full mt-2 group-hover:bg-[hsl(var(--primary))] group-hover:text-white group-hover:border-[hsl(var(--primary))] transition-all"
onClick={(e) => {
e.stopPropagation();
handleNewPresentation(client.id);

View file

@ -38,18 +38,10 @@ interface SystemSettings {
}
const PROVIDER_LABELS: Record<string, string> = {
anthropic: 'Anthropic (Claude)',
openai: 'OpenAI (GPT)',
google: 'Google (Gemini)',
ollama: 'Ollama (Local)',
custom: 'Custom',
nanobanana_pro: 'NanoBanana Pro (Gemini 3)',
gemini_flash: 'Gemini Flash',
'dall-e-3': 'DALL-E 3',
'gpt-image-1.5': 'GPT Image 1.5',
pexels: 'Pexels (Stock)',
pixabay: 'Pixabay (Stock)',
comfyui: 'ComfyUI',
google: 'Google Gemini',
gemini_flash: 'Gemini Flash (Image Generation)',
pexels: 'Pexels (Stock Photos)',
pixabay: 'Pixabay (Stock Photos)',
};
interface ConnectionTestResult {
@ -68,8 +60,6 @@ export default function SettingsPage() {
const [llmProvider, setLlmProvider] = useState('');
const [llmModel, setLlmModel] = useState('');
const [imageProvider, setImageProvider] = useState('');
const [anthropicKey, setAnthropicKey] = useState('');
const [openaiKey, setOpenaiKey] = useState('');
const [googleKey, setGoogleKey] = useState('');
// Model listing
@ -138,8 +128,6 @@ export default function SettingsPage() {
if (llmProvider !== settings?.llm_provider) body.llm_provider = llmProvider;
if (llmModel !== settings?.llm_model) body.llm_model = llmModel;
if (imageProvider !== settings?.image_provider) body.image_provider = imageProvider;
if (anthropicKey) body.anthropic_api_key = anthropicKey;
if (openaiKey) body.openai_api_key = openaiKey;
if (googleKey) body.google_api_key = googleKey;
if (Object.keys(body).length === 0) {
@ -158,8 +146,6 @@ export default function SettingsPage() {
throw new Error(err.detail || 'Save failed');
}
toast.success('Settings saved');
setAnthropicKey('');
setOpenaiKey('');
setGoogleKey('');
loadSettings();
} catch (e) {
@ -169,17 +155,11 @@ export default function SettingsPage() {
}
};
const testConnection = async (provider: string) => {
setTestingProvider(provider);
// Use the currently-typed key if available, otherwise test with stored key
const keyMap: Record<string, string> = {
anthropic: anthropicKey,
openai: openaiKey,
google: googleKey,
};
const testConnection = async () => {
setTestingProvider('google');
try {
const body: Record<string, string> = { provider };
if (keyMap[provider]) body.api_key = keyMap[provider];
const body: Record<string, string> = { provider: 'google' };
if (googleKey) body.api_key = googleKey;
const res = await fetch('/api/v1/admin/settings/test-connection', {
method: 'POST',
@ -187,14 +167,14 @@ export default function SettingsPage() {
body: JSON.stringify(body),
});
const result: ConnectionTestResult = await res.json();
setTestResults((prev) => ({ ...prev, [provider]: result }));
setTestResults((prev) => ({ ...prev, google: result }));
if (result.ok) {
toast.success(`${PROVIDER_LABELS[provider] || provider}: Connected (${result.latency_ms}ms)`);
toast.success(`Google Gemini: Connected (${result.latency_ms}ms)`);
} else {
toast.error(`${PROVIDER_LABELS[provider] || provider}: ${result.error}`);
toast.error(`Google Gemini: ${result.error}`);
}
} catch {
setTestResults((prev) => ({ ...prev, [provider]: { ok: false, error: 'Request failed' } }));
setTestResults((prev) => ({ ...prev, google: { ok: false, error: 'Request failed' } }));
toast.error('Connection test failed');
} finally {
setTestingProvider(null);
@ -234,61 +214,60 @@ export default function SettingsPage() {
</div>
<p className="text-sm text-gray-500">
Oliver DeckForge uses <strong>Google Gemini</strong> for all AI operations.
Settings are persisted to the database and survive container restarts.
Environment variables in .env / docker-compose.yml are used as defaults.
</p>
{/* LLM Configuration */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-sm text-blue-800">
<strong>Get your Google AI API key:</strong>{' '}
<a
href="https://aistudio.google.com/app/apikey"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-blue-600"
>
https://aistudio.google.com/app/apikey
</a>
</p>
</div>
{/* Gemini Configuration */}
<Card className="p-6 space-y-4">
<div className="flex items-center gap-2 mb-2">
<Cpu className="w-5 h-5 text-[#5146E5]" />
<h2 className="text-lg font-semibold">LLM Provider</h2>
<h2 className="text-lg font-semibold">Google Gemini (LLM)</h2>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Label>Provider</Label>
<Select value={llmProvider} onValueChange={setLlmProvider}>
<div className="space-y-1">
<Label>Model</Label>
{availableModels.length > 0 ? (
<Select value={llmModel} onValueChange={setLlmModel}>
<SelectTrigger>
<SelectValue />
{loadingModels ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<SelectValue placeholder="Select model" />
)}
</SelectTrigger>
<SelectContent>
{settings?.available_llm_providers.map((p) => (
<SelectItem key={p} value={p}>
{PROVIDER_LABELS[p] || p}
{availableModels.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Model</Label>
{availableModels.length > 0 ? (
<Select value={llmModel} onValueChange={setLlmModel}>
<SelectTrigger>
{loadingModels ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<SelectValue placeholder="Select model" />
)}
</SelectTrigger>
<SelectContent>
{availableModels.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={llmModel}
onChange={(e) => setLlmModel(e.target.value)}
placeholder={loadingModels ? 'Loading models...' : 'e.g. claude-sonnet-4-6'}
/>
)}
</div>
) : (
<Input
value={llmModel}
onChange={(e) => setLlmModel(e.target.value)}
placeholder={loadingModels ? 'Loading models...' : 'e.g. gemini-2.0-flash-exp'}
/>
)}
<p className="text-xs text-gray-500 mt-1">
Recommended: <code className="bg-gray-100 px-1 rounded">gemini-2.0-flash-exp</code> (fast & cheap)
</p>
</div>
</Card>
@ -316,120 +295,61 @@ export default function SettingsPage() {
</div>
</Card>
{/* API Keys */}
{/* API Key */}
<Card className="p-6 space-y-4">
<div className="flex items-center gap-2 mb-2">
<Key className="w-5 h-5 text-orange-600" />
<h2 className="text-lg font-semibold">API Keys</h2>
<h2 className="text-lg font-semibold">Google API Key</h2>
</div>
<p className="text-xs text-gray-400">
Leave blank to keep existing keys. Enter a new value to update.
Leave blank to keep existing key. Enter a new value to update.
</p>
<div className="space-y-3">
<ApiKeyField
label="Anthropic API Key"
provider="anthropic"
isSet={!!settings?.anthropic_api_key_set}
value={anthropicKey}
onChange={setAnthropicKey}
placeholder={settings?.anthropic_api_key_set ? '••••••••••••' : 'sk-ant-...'}
testResult={testResults.anthropic}
isTesting={testingProvider === 'anthropic'}
onTest={() => testConnection('anthropic')}
/>
<ApiKeyField
label="OpenAI API Key"
provider="openai"
isSet={!!settings?.openai_api_key_set}
value={openaiKey}
onChange={setOpenaiKey}
placeholder={settings?.openai_api_key_set ? '••••••••••••' : 'sk-...'}
testResult={testResults.openai}
isTesting={testingProvider === 'openai'}
onTest={() => testConnection('openai')}
/>
<ApiKeyField
label="Google API Key"
provider="google"
isSet={!!settings?.google_api_key_set}
value={googleKey}
onChange={setGoogleKey}
placeholder={settings?.google_api_key_set ? '••••••••••••' : 'AIza...'}
testResult={testResults.google}
isTesting={testingProvider === 'google'}
onTest={() => testConnection('google')}
/>
<div className="space-y-1">
<Label className="flex items-center gap-2">
API Key
{settings?.google_api_key_set && (
<span className="text-[10px] px-1.5 py-0.5 bg-green-100 text-green-700 rounded-full">Set</span>
)}
{testResults.google && (
testResults.google.ok ? (
<span className="text-[10px] px-1.5 py-0.5 bg-green-100 text-green-700 rounded-full flex items-center gap-0.5">
<CheckCircle2 className="w-2.5 h-2.5" /> OK {testResults.google.latency_ms && `(${testResults.google.latency_ms}ms)`}
</span>
) : (
<span className="text-[10px] px-1.5 py-0.5 bg-red-100 text-red-700 rounded-full flex items-center gap-0.5">
<XCircle className="w-2.5 h-2.5" /> Failed
</span>
)
)}
</Label>
<div className="flex gap-2">
<Input
type="password"
value={googleKey}
onChange={(e) => setGoogleKey(e.target.value)}
placeholder={settings?.google_api_key_set ? '••••••••••••' : 'AIza...'}
className="flex-1"
/>
<Button
variant="outline"
size="sm"
onClick={testConnection}
disabled={testingProvider === 'google'}
className="h-9 px-3 text-xs whitespace-nowrap"
title="Test connection"
>
{testingProvider === 'google' ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Zap className="w-3.5 h-3.5" />
)}
Test
</Button>
</div>
</div>
</Card>
</div>
);
}
function ApiKeyField({
label,
provider,
isSet,
value,
onChange,
placeholder,
testResult,
isTesting,
onTest,
}: {
label: string;
provider: string;
isSet: boolean;
value: string;
onChange: (v: string) => void;
placeholder: string;
testResult?: ConnectionTestResult;
isTesting: boolean;
onTest: () => void;
}) {
return (
<div className="space-y-1">
<Label className="flex items-center gap-2">
{label}
{isSet && (
<span className="text-[10px] px-1.5 py-0.5 bg-green-100 text-green-700 rounded-full">Set</span>
)}
{testResult && (
testResult.ok ? (
<span className="text-[10px] px-1.5 py-0.5 bg-green-100 text-green-700 rounded-full flex items-center gap-0.5">
<CheckCircle2 className="w-2.5 h-2.5" /> OK {testResult.latency_ms && `(${testResult.latency_ms}ms)`}
</span>
) : (
<span className="text-[10px] px-1.5 py-0.5 bg-red-100 text-red-700 rounded-full flex items-center gap-0.5">
<XCircle className="w-2.5 h-2.5" /> Failed
</span>
)
)}
</Label>
<div className="flex gap-2">
<Input
type="password"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="flex-1"
/>
<Button
variant="outline"
size="sm"
onClick={onTest}
disabled={isTesting}
className="h-9 px-3 text-xs whitespace-nowrap"
title="Test connection"
>
{isTesting ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Zap className="w-3.5 h-3.5" />
)}
Test
</Button>
</div>
</div>
);
}

View file

@ -15,77 +15,201 @@ body {
@layer base {
:root {
/* Brand-adaptive colors (set dynamically via useBrandTheme hook) */
/* ========================================
OLIVER DECKFORGE DESIGN SYSTEM
Modern, professional design tokens
======================================== */
/* Primary Brand Colors */
--brand-primary: #5146E5;
--brand-secondary: #E9E8F8;
--brand-accent: #3D35B0;
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
/* Primary (Brand Purple) */
--primary: 250 70% 60%; /* #5146E5 */
--primary-hover: 250 70% 55%;
--primary-light: 250 70% 95%;
--primary-foreground: 0 0% 100%;
/* Semantic Colors */
--success: 142 76% 36%; /* Green */
--success-foreground: 0 0% 100%;
--warning: 38 92% 50%; /* Amber */
--warning-foreground: 0 0% 100%;
--error: 0 84% 60%; /* Red */
--error-foreground: 0 0% 100%;
--info: 199 89% 48%; /* Blue */
--info-foreground: 0 0% 100%;
/* Neutrals */
--background: 0 0% 100%; /* White */
--foreground: 0 0% 10%; /* Near black */
--surface: 0 0% 98%; /* Light gray bg */
--surface-hover: 0 0% 95%;
--text-primary: 0 0% 10%;
--text-secondary: 0 0% 45%;
--text-tertiary: 0 0% 65%;
/* Shadcn UI compatibility */
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--card-foreground: 0 0% 10%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--popover-foreground: 0 0% 10%;
--secondary: 0 0% 96%;
--secondary-foreground: 0 0% 10%;
--muted: 0 0% 96%;
--muted-foreground: 0 0% 45%;
--accent: 250 70% 95%;
--accent-foreground: 250 70% 20%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 10% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
/* Borders & Inputs */
--border: 0 0% 90%;
--input: 0 0% 90%;
--ring: 250 70% 60%;
/* Charts */
--chart-1: 250 70% 60%; /* Brand purple */
--chart-2: 142 76% 36%; /* Success green */
--chart-3: 38 92% 50%; /* Warning amber */
--chart-4: 199 89% 48%; /* Info blue */
--chart-5: 0 84% 60%; /* Error red */
/* Shadows (HSL format for Tailwind) */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1);
/* Radius */
--radius: 0.75rem; /* 12px - more modern */
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--background: 222 47% 11%; /* Dark blue-gray */
--foreground: 0 0% 95%;
--surface: 217 33% 17%;
--surface-hover: 217 33% 20%;
--text-primary: 0 0% 95%;
--text-secondary: 0 0% 65%;
--text-tertiary: 0 0% 45%;
/* Shadcn UI dark mode */
--card: 217 33% 17%;
--card-foreground: 0 0% 95%;
--popover: 217 33% 17%;
--popover-foreground: 0 0% 95%;
--primary: 250 70% 60%;
--primary-foreground: 0 0% 100%;
--secondary: 217 33% 25%;
--secondary-foreground: 0 0% 95%;
--muted: 217 33% 25%;
--muted-foreground: 0 0% 65%;
--accent: 250 70% 30%;
--accent-foreground: 0 0% 95%;
--destructive: 0 62% 40%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--border: 217 33% 25%;
--input: 217 33% 25%;
--ring: 250 70% 60%;
--chart-1: 250 70% 60%;
--chart-2: 142 76% 46%;
--chart-3: 38 92% 60%;
--chart-4: 199 89% 58%;
--chart-5: 0 84% 70%;
}
}
@layer base {
body {
@apply bg-background text-foreground;
}
/* Typography Scale */
.h1 {
@apply text-4xl font-bold tracking-tight;
}
.h2 {
@apply text-3xl font-semibold tracking-tight;
}
.h3 {
@apply text-2xl font-semibold;
}
.h4 {
@apply text-xl font-semibold;
}
.body-lg {
@apply text-lg leading-relaxed;
}
.body {
@apply text-base leading-normal;
}
.body-sm {
@apply text-sm leading-snug;
}
.caption {
@apply text-xs text-muted-foreground;
}
}
strong {
@apply font-black;
}
/* Modern UI Animations */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideIn {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.animate-fadeIn {
animation: fadeIn 0.3s ease-out;
}
.animate-slideIn {
animation: slideIn 0.3s ease-out;
}
.animate-scaleIn {
animation: scaleIn 0.2s ease-out;
}
::selection {

View file

@ -0,0 +1,69 @@
import { cn } from "@/lib/utils";
interface CardProps {
children: React.ReactNode;
hover?: boolean;
className?: string;
onClick?: () => void;
}
export function Card({ children, hover = false, className = "", onClick }: CardProps) {
return (
<div
className={cn(
"bg-white rounded-xl border border-border p-6",
"transition-all duration-200",
hover && "hover:shadow-lg hover:border-[hsl(var(--primary))]/20 cursor-pointer hover:-translate-y-1",
className
)}
onClick={onClick}
>
{children}
</div>
);
}
interface CardHeaderProps {
children: React.ReactNode;
className?: string;
}
export function CardHeader({ children, className = "" }: CardHeaderProps) {
return <div className={cn("mb-4", className)}>{children}</div>;
}
interface CardTitleProps {
children: React.ReactNode;
className?: string;
}
export function CardTitle({ children, className = "" }: CardTitleProps) {
return <h3 className={cn("text-xl font-semibold", className)}>{children}</h3>;
}
interface CardDescriptionProps {
children: React.ReactNode;
className?: string;
}
export function CardDescription({ children, className = "" }: CardDescriptionProps) {
return <p className={cn("text-sm text-muted-foreground", className)}>{children}</p>;
}
interface CardContentProps {
children: React.ReactNode;
className?: string;
}
export function CardContent({ children, className = "" }: CardContentProps) {
return <div className={cn(className)}>{children}</div>;
}
interface CardFooterProps {
children: React.ReactNode;
className?: string;
}
export function CardFooter({ children, className = "" }: CardFooterProps) {
return <div className={cn("mt-4 pt-4 border-t border-border", className)}>{children}</div>;
}

View file

@ -0,0 +1,34 @@
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
type PresentationStatus = "draft" | "in_review" | "approved";
const STATUS_CONFIG = {
draft: {
label: "Draft",
color: "bg-gray-100 text-gray-800 border-gray-200",
},
in_review: {
label: "In Review",
color: "bg-yellow-100 text-yellow-800 border-yellow-200",
},
approved: {
label: "Approved",
color: "bg-green-100 text-green-800 border-green-200",
},
} as const;
interface StatusBadgeProps {
status: PresentationStatus;
className?: string;
}
export function StatusBadge({ status, className = "" }: StatusBadgeProps) {
const config = STATUS_CONFIG[status] || STATUS_CONFIG.draft;
return (
<Badge className={cn(config.color, "border font-medium", className)}>
{config.label}
</Badge>
);
}

View file

@ -5,26 +5,30 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
"bg-[hsl(var(--primary))] text-primary-foreground shadow-md hover:shadow-lg hover:bg-[hsl(var(--primary-hover))] active:scale-95",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
"bg-[hsl(var(--error))] text-error-foreground shadow-md hover:shadow-lg hover:bg-[hsl(var(--error))]/90 active:scale-95",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
"border-2 border-border bg-background hover:bg-[hsl(var(--surface))] hover:border-[hsl(var(--primary))] active:scale-95",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
"bg-[hsl(var(--surface))] text-foreground hover:bg-[hsl(var(--surface-hover))] shadow-sm active:scale-95",
ghost:
"hover:bg-[hsl(var(--surface))] active:scale-95",
link:
"text-[hsl(var(--primary))] underline-offset-4 hover:underline",
success:
"bg-[hsl(var(--success))] text-success-foreground shadow-md hover:shadow-lg hover:bg-[hsl(var(--success))]/90 active:scale-95",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
default: "h-10 px-4 py-2 text-base",
sm: "h-9 px-3 text-sm",
lg: "h-12 px-6 text-lg",
icon: "h-10 w-10",
},
},
defaultVariants: {

View file

@ -25,12 +25,12 @@ export function HamsterLoader({ size = 'md', className = '' }: HamsterLoaderProp
>
<div className="wheel" />
<div className="hamster">
<div className="hamster__head">
<div className="hamster__ear" />
<div className="hamster__eye" />
<div className="hamster__nose" />
</div>
<div className="hamster__body">
<div className="hamster__head">
<div className="hamster__ear" />
<div className="hamster__eye" />
<div className="hamster__nose" />
</div>
<div className="hamster__limb hamster__limb--fr" />
<div className="hamster__limb hamster__limb--fl" />
<div className="hamster__limb hamster__limb--br" />