diff --git a/servers/fastapi/api/v1/ppt/endpoints/fonts.py b/servers/fastapi/api/v1/ppt/endpoints/fonts.py
index ecf7ca83..0160698f 100644
--- a/servers/fastapi/api/v1/ppt/endpoints/fonts.py
+++ b/servers/fastapi/api/v1/ppt/endpoints/fonts.py
@@ -1,290 +1,251 @@
import os
import uuid
-import shutil
-from pathlib import Path
-from typing import List, Dict, Any, Optional
-from fastapi import APIRouter, HTTPException, File, UploadFile
+from typing import Any, List, Optional
+
+from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from pydantic import BaseModel
-from utils.asset_directory_utils import get_app_data_directory_env
-import uuid
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlmodel import select
+
+from models.sql.key_value import KeyValueSqlModel
+from services.database import get_async_session
+from utils.get_env import get_app_data_directory_env
try:
from fontTools.ttLib import TTFont
- from fontTools.ttLib.tables._n_a_m_e import table__n_a_m_e
+
FONTTOOLS_AVAILABLE = True
except ImportError:
FONTTOOLS_AVAILABLE = False
FONTS_ROUTER = APIRouter(prefix="/fonts", tags=["fonts"])
+FONTS_STORAGE_KEY = "presentation_uploaded_fonts"
-# Supported font file extensions
SUPPORTED_FONT_EXTENSIONS = {
- '.ttf': 'font/ttf',
- '.otf': 'font/otf',
- '.woff': 'font/woff',
- '.woff2': 'font/woff2',
- '.eot': 'application/vnd.ms-fontobject'
+ ".ttf": "font/ttf",
+ ".otf": "font/otf",
+ ".woff": "font/woff",
+ ".woff2": "font/woff2",
+ ".eot": "application/vnd.ms-fontobject",
}
-class FontUploadResponse(BaseModel):
- success: bool
+
+class FontDetail(BaseModel):
+ id: str
+ name: str
+ url: str
+
+
+class FontUploadResponse(FontDetail):
+ success: bool = True
font_name: str
font_url: str
font_path: str
- message: Optional[str] = None
+
class FontListResponse(BaseModel):
- success: bool
- fonts: List[dict]
- message: Optional[str] = None
+ fonts: List[FontDetail]
-def get_fonts_directory() -> str:
- """Get the fonts directory path, create if it doesn't exist"""
+def _get_fonts_directory() -> str:
app_data_dir = get_app_data_directory_env() or "/tmp/presenton"
fonts_dir = os.path.join(app_data_dir, "fonts")
os.makedirs(fonts_dir, exist_ok=True)
return fonts_dir
-def is_valid_font_file(file: UploadFile) -> bool:
- """Validate font file by extension and MIME type"""
+def _extract_font_name_from_file(file_path: str, filename: str) -> str:
+ fallback_name = os.path.splitext(filename)[0]
+
+ if not FONTTOOLS_AVAILABLE:
+ return fallback_name
+
+ try:
+ font = TTFont(file_path)
+ if "name" not in font:
+ font.close()
+ return fallback_name
+
+ name_table = font["name"]
+ for name_id in [1, 4, 6]:
+ for record in name_table.names:
+ if record.nameID == name_id:
+ if record.langID in [0x409, 0]:
+ font_name = record.toUnicode().strip()
+ if font_name:
+ font.close()
+ return font_name
+
+ for record in name_table.names:
+ if record.nameID == 1:
+ font_name = record.toUnicode().strip()
+ if font_name:
+ font.close()
+ return font_name
+
+ font.close()
+ except Exception:
+ return fallback_name
+
+ return fallback_name
+
+
+def _is_valid_font_file(file: UploadFile) -> bool:
if not file.filename:
return False
-
+
file_ext = os.path.splitext(file.filename)[1].lower()
if file_ext not in SUPPORTED_FONT_EXTENSIONS:
return False
-
- # Check MIME type
- content_type = file.content_type or ""
- valid_mime_types = [
- "font/ttf", "font/otf", "font/woff", "font/woff2",
- "application/font-ttf", "application/font-otf",
- "application/font-woff", "application/font-woff2",
- "application/x-font-ttf", "application/x-font-otf",
- "font/truetype", "font/opentype"
- ]
-
+
+ content_type = (file.content_type or "").lower()
+ valid_mime_types = {
+ "font/ttf",
+ "font/otf",
+ "font/woff",
+ "font/woff2",
+ "application/font-ttf",
+ "application/font-otf",
+ "application/font-woff",
+ "application/font-woff2",
+ "application/x-font-ttf",
+ "application/x-font-otf",
+ "font/truetype",
+ "font/opentype",
+ "application/octet-stream",
+ "",
+ }
+
return content_type in valid_mime_types
-def extract_font_name_from_file(file_path: str) -> str:
- """Extract the actual font family name from font file metadata"""
- if not FONTTOOLS_AVAILABLE:
- # Fallback to filename parsing if fonttools not available
- filename = os.path.basename(file_path)
- base_name = os.path.splitext(filename)[0]
- if '_' in filename and len(filename.split('_')[-1].split('.')[0]) == 8:
- # Remove UUID part
- parts = filename.split('_')
- if len(parts) > 1:
- return '_'.join(parts[:-1])
- return base_name
-
- try:
- font = TTFont(file_path)
-
- # Try to get font family name from name table
- if 'name' in font:
- name_table = font['name']
-
- # Preferred order: Family name (ID 1), then Full name (ID 4), then PostScript name (ID 6)
- for name_id in [1, 4, 6]:
- for record in name_table.names:
- if record.nameID == name_id:
- # Prefer English names
- if record.langID == 0x409 or record.langID == 0: # English
- font_name = record.toUnicode().strip()
- if font_name:
- font.close()
- return font_name
-
- # If no English name found, use any available family name
- for record in name_table.names:
- if record.nameID == 1: # Family name
- font_name = record.toUnicode().strip()
- if font_name:
- font.close()
- return font_name
-
- font.close()
- except Exception as e:
- # If font parsing fails, fallback to filename
- print(f"Error reading font metadata from {file_path}: {e}")
-
- # Fallback to filename parsing
- filename = os.path.basename(file_path)
- base_name = os.path.splitext(filename)[0]
- if '_' in filename and len(filename.split('_')[-1].split('.')[0]) == 8:
- # Remove UUID part
- parts = filename.split('_')
- if len(parts) > 1:
- return '_'.join(parts[:-1])
- return base_name
+async def _get_fonts_row(sql_session: AsyncSession) -> Optional[KeyValueSqlModel]:
+ return await sql_session.scalar(
+ select(KeyValueSqlModel).where(KeyValueSqlModel.key == FONTS_STORAGE_KEY)
+ )
+
+
+def _read_fonts_from_row(row: Optional[KeyValueSqlModel]) -> list[dict[str, Any]]:
+ if not row:
+ return []
+ value = row.value if isinstance(row.value, dict) else {}
+ fonts = value.get("fonts", [])
+ return fonts if isinstance(fonts, list) else []
@FONTS_ROUTER.post("/upload", response_model=FontUploadResponse)
async def upload_font(
- font_file: UploadFile = File(..., description="Font file to upload (.ttf, .otf, .woff, .woff2, .eot)")
+ file: Optional[UploadFile] = File(
+ None, description="Font file to upload (.ttf, .otf, .woff, .woff2, .eot)"
+ ),
+ font_file: Optional[UploadFile] = File(None),
+ sql_session: AsyncSession = Depends(get_async_session),
):
- """
- Upload a font file and save it to the fonts directory.
-
- Args:
- font_file: Uploaded font file
-
- Returns:
- FontUploadResponse with font details and accessible URL
-
- Raises:
- HTTPException: If file validation fails or upload error occurs
- """
+ upload_file = file or font_file
+ if not upload_file:
+ raise HTTPException(status_code=400, detail="No file provided")
+
+ if not upload_file.filename:
+ raise HTTPException(status_code=400, detail="No file name provided")
+
+ if not _is_valid_font_file(upload_file):
+ raise HTTPException(
+ status_code=400,
+ detail=f"Invalid font file. Supported formats: {', '.join(SUPPORTED_FONT_EXTENSIONS.keys())}",
+ )
+
+ file_ext = os.path.splitext(upload_file.filename)[1].lower()
+ unique_filename = f"{uuid.uuid4().hex}{file_ext}"
+ fonts_dir = _get_fonts_directory()
+ font_path = os.path.join(fonts_dir, unique_filename)
+
try:
- # Validate file
- if not font_file.filename:
- raise HTTPException(
- status_code=400,
- detail="No file name provided"
- )
-
- if not is_valid_font_file(font_file):
- raise HTTPException(
- status_code=400,
- detail=f"Invalid font file. Supported formats: {', '.join(SUPPORTED_FONT_EXTENSIONS.keys())}"
- )
-
- # Generate unique filename to avoid conflicts
- file_ext = os.path.splitext(font_file.filename)[1].lower()
- base_name = os.path.splitext(font_file.filename)[0]
- unique_filename = f"{base_name}_{str(uuid.uuid4())[:8]}{file_ext}"
-
- # Get fonts directory
- fonts_dir = get_fonts_directory()
- font_path = os.path.join(fonts_dir, unique_filename)
-
- # Save the uploaded file
+ contents = await upload_file.read()
with open(font_path, "wb") as buffer:
- shutil.copyfileobj(font_file.file, buffer)
-
- # Generate accessible URL
- font_url = f"/app_data/fonts/{unique_filename}"
-
- return FontUploadResponse(
- success=True,
- font_name=base_name,
- font_url=font_url,
- font_path=font_path,
- message=f"Font '{base_name}' uploaded successfully"
- )
-
- except HTTPException:
- # Re-raise HTTP exceptions as-is
- raise
- except Exception as e:
- print(f"Error uploading font: {str(e)}")
- raise HTTPException(
- status_code=500,
- detail=f"Error uploading font: {str(e)}"
- )
+ buffer.write(contents)
+ except Exception as exc:
+ raise HTTPException(status_code=500, detail="Error uploading font") from exc
+
+ font_name = _extract_font_name_from_file(font_path, upload_file.filename)
+ font_url = f"/app_data/fonts/{unique_filename}"
+ font_detail = {
+ "id": str(uuid.uuid4()),
+ "name": font_name,
+ "url": font_url,
+ "path": font_path,
+ }
+
+ row = await _get_fonts_row(sql_session)
+ fonts = _read_fonts_from_row(row)
+ fonts.append(font_detail)
+
+ if row:
+ row.value = {"fonts": fonts}
+ sql_session.add(row)
+ else:
+ sql_session.add(KeyValueSqlModel(key=FONTS_STORAGE_KEY, value={"fonts": fonts}))
+ await sql_session.commit()
+
+ return FontUploadResponse(
+ id=font_detail["id"],
+ name=font_detail["name"],
+ url=font_detail["url"],
+ font_name=font_detail["name"],
+ font_url=font_detail["url"],
+ font_path=font_detail["path"],
+ )
-@FONTS_ROUTER.get("/list", response_model=FontListResponse)
-async def list_fonts():
- """
- List all uploaded fonts with their accessible URLs.
-
- Returns:
- FontListResponse with list of available fonts
- """
- try:
- fonts_dir = get_fonts_directory()
- fonts = []
-
- # Get all font files in the directory
- if os.path.exists(fonts_dir):
- for filename in os.listdir(fonts_dir):
- file_path = os.path.join(fonts_dir, filename)
-
- if os.path.isfile(file_path):
- file_ext = os.path.splitext(filename)[1].lower()
-
- if file_ext in SUPPORTED_FONT_EXTENSIONS:
- # Get the real font name from file metadata
- font_name = extract_font_name_from_file(file_path)
-
- # Extract original name (remove UUID suffix for display)
- base_name = filename
- if '_' in filename and len(filename.split('_')[-1].split('.')[0]) == 8:
- # Remove UUID part for original_name display
- parts = filename.split('_')
- if len(parts) > 1:
- base_name = '_'.join(parts[:-1]) + file_ext
-
- fonts.append({
- "filename": filename,
- "font_name": font_name, # Real font family name from metadata
- "original_name": base_name,
- "font_url": f"/app_data/fonts/{filename}",
- "font_type": SUPPORTED_FONT_EXTENSIONS.get(file_ext, 'unknown'),
- "file_size": os.path.getsize(file_path)
- })
-
- return FontListResponse(
- success=True,
- fonts=fonts,
- message=f"Found {len(fonts)} font files"
- )
-
- except Exception as e:
- print(f"Error listing fonts: {str(e)}")
- raise HTTPException(
- status_code=500,
- detail=f"Error listing fonts: {str(e)}"
- )
+@FONTS_ROUTER.get("/uploaded", response_model=FontListResponse)
+async def get_uploaded_fonts(sql_session: AsyncSession = Depends(get_async_session)):
+ row = await _get_fonts_row(sql_session)
+ fonts = _read_fonts_from_row(row)
+ valid_fonts = []
+ for font in fonts:
+ path = font.get("path")
+ if isinstance(path, str) and os.path.exists(path):
+ valid_fonts.append(font)
-@FONTS_ROUTER.delete("/delete/{filename}")
-async def delete_font(filename: str):
- """
- Delete a font file from the fonts directory.
-
- Args:
- filename: Name of the font file to delete
-
- Returns:
- Success message
- """
- try:
- fonts_dir = get_fonts_directory()
- font_path = os.path.join(fonts_dir, filename)
-
- if not os.path.exists(font_path):
- raise HTTPException(
- status_code=404,
- detail=f"Font file '{filename}' not found"
+ if row and len(valid_fonts) != len(fonts):
+ row.value = {"fonts": valid_fonts}
+ sql_session.add(row)
+ await sql_session.commit()
+
+ return FontListResponse(
+ fonts=[
+ FontDetail(
+ id=str(item.get("id", "")),
+ name=str(item.get("name", "")),
+ url=str(item.get("url", "")),
)
-
- # Validate it's actually a font file before deleting
- file_ext = os.path.splitext(filename.lower())[1]
- if file_ext not in SUPPORTED_FONT_EXTENSIONS:
- raise HTTPException(
- status_code=400,
- detail="File is not a recognized font format"
- )
-
- os.remove(font_path)
-
- return {
- "success": True,
- "message": f"Font '{filename}' deleted successfully"
- }
-
- except HTTPException:
- raise
- except Exception as e:
- print(f"Error deleting font: {str(e)}")
- raise HTTPException(
- status_code=500,
- detail=f"Error deleting font: {str(e)}"
- )
\ No newline at end of file
+ for item in valid_fonts
+ ]
+ )
+
+
+@FONTS_ROUTER.delete("/{font_id}", status_code=204)
+async def delete_uploaded_font(
+ font_id: str, sql_session: AsyncSession = Depends(get_async_session)
+):
+ row = await _get_fonts_row(sql_session)
+ if not row:
+ raise HTTPException(status_code=404, detail="Font not found")
+
+ fonts = _read_fonts_from_row(row)
+ target_font = next((item for item in fonts if str(item.get("id")) == font_id), None)
+ if not target_font:
+ raise HTTPException(status_code=404, detail="Font not found")
+
+ path = target_font.get("path")
+ if isinstance(path, str) and os.path.exists(path):
+ try:
+ os.remove(path)
+ except OSError:
+ # Keep metadata cleanup resilient even if local file is already gone/locked.
+ pass
+
+ updated_fonts = [item for item in fonts if str(item.get("id")) != font_id]
+ row.value = {"fonts": updated_fonts}
+ sql_session.add(row)
+ await sql_session.commit()
\ No newline at end of file
diff --git a/servers/fastapi/api/v1/ppt/endpoints/theme.py b/servers/fastapi/api/v1/ppt/endpoints/theme.py
new file mode 100644
index 00000000..4ddd38d9
--- /dev/null
+++ b/servers/fastapi/api/v1/ppt/endpoints/theme.py
@@ -0,0 +1,178 @@
+import uuid
+from typing import Any, List, Optional
+
+from fastapi import APIRouter, Depends, HTTPException
+from pydantic import BaseModel, Field
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlmodel import select
+
+from models.sql.image_asset import ImageAsset
+from models.sql.key_value import KeyValueSqlModel
+from services.database import get_async_session
+
+THEMES_ROUTER = APIRouter(prefix="/themes", tags=["Themes"])
+THEMES_STORAGE_KEY = "presentation_custom_themes"
+
+
+class ThemeRequest(BaseModel):
+ name: str
+ description: str
+ company_name: Optional[str] = None
+ logo: Optional[str] = None
+ logo_url: Optional[str] = None
+ data: dict[str, Any] = Field(default_factory=dict)
+
+
+class ThemeUpdateRequest(BaseModel):
+ name: Optional[str] = None
+ description: Optional[str] = None
+ company_name: Optional[str] = None
+ logo: Optional[str] = None
+ logo_url: Optional[str] = None
+ data: Optional[dict[str, Any]] = None
+
+
+class ThemeResponse(BaseModel):
+ id: str
+ name: str
+ description: str
+ user: str
+ logo: Optional[str] = None
+ logo_url: Optional[str] = None
+ company_name: Optional[str] = None
+ data: dict[str, Any]
+
+
+def _normalize_theme(theme: dict[str, Any]) -> ThemeResponse:
+ return ThemeResponse(
+ id=str(theme["id"]),
+ name=theme["name"],
+ description=theme["description"],
+ user=theme.get("user", "local"),
+ logo=theme.get("logo"),
+ logo_url=theme.get("logo_url"),
+ company_name=theme.get("company_name"),
+ data=theme.get("data", {}),
+ )
+
+
+async def _get_themes_row(sql_session: AsyncSession) -> Optional[KeyValueSqlModel]:
+ return await sql_session.scalar(
+ select(KeyValueSqlModel).where(KeyValueSqlModel.key == THEMES_STORAGE_KEY)
+ )
+
+
+def _read_themes_from_row(row: Optional[KeyValueSqlModel]) -> list[dict[str, Any]]:
+ if not row:
+ return []
+ value = row.value if isinstance(row.value, dict) else {}
+ themes = value.get("themes", [])
+ return themes if isinstance(themes, list) else []
+
+
+async def _resolve_logo_url(
+ sql_session: AsyncSession, logo: Optional[str]
+) -> Optional[str]:
+ if not logo:
+ return None
+ try:
+ logo_uuid = uuid.UUID(str(logo))
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail="Invalid logo id") from exc
+
+ image_asset = await sql_session.get(ImageAsset, logo_uuid)
+ if not image_asset:
+ raise HTTPException(status_code=404, detail="Logo not found")
+ return image_asset.path
+
+
+@THEMES_ROUTER.get("/default", response_model=List[dict[str, Any]])
+async def get_default_themes():
+ # Built-in themes are provided by Next.js constants in this project.
+ return []
+
+
+@THEMES_ROUTER.get("/all", response_model=List[ThemeResponse])
+async def get_themes(sql_session: AsyncSession = Depends(get_async_session)):
+ row = await _get_themes_row(sql_session)
+ themes = _read_themes_from_row(row)
+ return [_normalize_theme(theme) for theme in themes]
+
+
+@THEMES_ROUTER.post("/create", response_model=ThemeResponse)
+async def create_theme(
+ payload: ThemeRequest, sql_session: AsyncSession = Depends(get_async_session)
+):
+ row = await _get_themes_row(sql_session)
+ themes = _read_themes_from_row(row)
+ logo_url = payload.logo_url or await _resolve_logo_url(sql_session, payload.logo)
+
+ theme = {
+ "id": str(uuid.uuid4()),
+ "name": payload.name,
+ "description": payload.description,
+ "user": "local",
+ "logo": payload.logo,
+ "logo_url": logo_url,
+ "company_name": payload.company_name,
+ "data": payload.data,
+ }
+ themes.append(theme)
+
+ if row:
+ row.value = {"themes": themes}
+ sql_session.add(row)
+ else:
+ sql_session.add(KeyValueSqlModel(key=THEMES_STORAGE_KEY, value={"themes": themes}))
+
+ await sql_session.commit()
+ return _normalize_theme(theme)
+
+
+@THEMES_ROUTER.patch("/update/{theme_id}", response_model=ThemeResponse)
+async def update_theme(
+ theme_id: str,
+ payload: ThemeUpdateRequest,
+ sql_session: AsyncSession = Depends(get_async_session),
+):
+ row = await _get_themes_row(sql_session)
+ if not row:
+ raise HTTPException(status_code=404, detail="Theme not found")
+
+ themes = _read_themes_from_row(row)
+ theme = next((item for item in themes if item.get("id") == theme_id), None)
+ if not theme:
+ raise HTTPException(status_code=404, detail="Theme not found")
+
+ if payload.name is not None:
+ theme["name"] = payload.name
+ if payload.description is not None:
+ theme["description"] = payload.description
+ if payload.company_name is not None:
+ theme["company_name"] = payload.company_name
+ if payload.data is not None:
+ theme["data"] = payload.data
+ if payload.logo is not None:
+ theme["logo"] = payload.logo
+ theme["logo_url"] = await _resolve_logo_url(sql_session, payload.logo)
+ elif payload.logo_url is not None:
+ theme["logo_url"] = payload.logo_url
+
+ row.value = {"themes": themes}
+ sql_session.add(row)
+ await sql_session.commit()
+ return _normalize_theme(theme)
+
+
+@THEMES_ROUTER.delete("/delete/{theme_id}", status_code=204)
+async def delete_theme(
+ theme_id: str, sql_session: AsyncSession = Depends(get_async_session)
+):
+ row = await _get_themes_row(sql_session)
+ if not row:
+ return
+
+ themes = _read_themes_from_row(row)
+ row.value = {"themes": [theme for theme in themes if theme.get("id") != theme_id]}
+ sql_session.add(row)
+ await sql_session.commit()
diff --git a/servers/fastapi/api/v1/ppt/endpoints/theme_generate.py b/servers/fastapi/api/v1/ppt/endpoints/theme_generate.py
new file mode 100644
index 00000000..94590feb
--- /dev/null
+++ b/servers/fastapi/api/v1/ppt/endpoints/theme_generate.py
@@ -0,0 +1,75 @@
+from typing import Optional
+
+from fastapi import APIRouter
+from pydantic import BaseModel
+
+from models.theme_data import ThemeData
+from utils.theme_utils import (
+ IS_DARK_BELOW,
+ generate_color_palette,
+ get_lightness_key_at_distance,
+)
+
+THEME_ROUTER = APIRouter(prefix="/theme", tags=["V3 Theme"])
+
+
+class GenerateThemeRequestV3(BaseModel):
+ primary: Optional[str] = None
+ background: Optional[str] = None
+ accent_1: Optional[str] = None
+ accent_2: Optional[str] = None
+ text_1: Optional[str] = None
+ text_2: Optional[str] = None
+
+
+@THEME_ROUTER.post("/generate", response_model=ThemeData)
+async def generate_theme_v3(request: GenerateThemeRequestV3) -> ThemeData:
+ color_palette = generate_color_palette(
+ request.primary,
+ request.background,
+ request.accent_1,
+ request.accent_2,
+ request.text_1,
+ request.text_2,
+ )
+
+ is_dark_theme = color_palette.background_lightness < IS_DARK_BELOW
+ graph_colors = list(color_palette.primary_variations.values())
+
+ if not is_dark_theme:
+ graph_colors.reverse()
+
+ theme_data = ThemeData(
+ primary=color_palette.primary,
+ background=color_palette.background,
+ card=color_palette.background_variations[
+ get_lightness_key_at_distance(
+ color_palette.background_lightness,
+ min_distance=1,
+ max_distance=1,
+ prefer_dark=not is_dark_theme,
+ )
+ ],
+ stroke=color_palette.background_variations[
+ get_lightness_key_at_distance(
+ color_palette.background_lightness,
+ min_distance=2,
+ max_distance=2,
+ prefer_dark=not is_dark_theme,
+ )
+ ],
+ background_text=color_palette.text_1,
+ primary_text=color_palette.text_2,
+ graph_0=graph_colors[0],
+ graph_1=graph_colors[1],
+ graph_2=graph_colors[2],
+ graph_3=graph_colors[3],
+ graph_4=graph_colors[4],
+ graph_5=graph_colors[5],
+ graph_6=graph_colors[6],
+ graph_7=graph_colors[7],
+ graph_8=graph_colors[8],
+ graph_9=graph_colors[9],
+ )
+ return theme_data
+
diff --git a/servers/fastapi/api/v1/ppt/router.py b/servers/fastapi/api/v1/ppt/router.py
index 3449e22a..fad0cf99 100644
--- a/servers/fastapi/api/v1/ppt/router.py
+++ b/servers/fastapi/api/v1/ppt/router.py
@@ -16,6 +16,8 @@ from api.v1.ppt.endpoints.ollama import OLLAMA_ROUTER
from api.v1.ppt.endpoints.outlines import OUTLINES_ROUTER
from api.v1.ppt.endpoints.slide import SLIDE_ROUTER
from api.v1.ppt.endpoints.pptx_slides import PPTX_FONTS_ROUTER
+from api.v1.ppt.endpoints.theme import THEMES_ROUTER
+from api.v1.ppt.endpoints.theme_generate import THEME_ROUTER
API_V1_PPT_ROUTER = APIRouter(prefix="/api/v1/ppt")
@@ -39,3 +41,5 @@ API_V1_PPT_ROUTER.include_router(ANTHROPIC_ROUTER)
API_V1_PPT_ROUTER.include_router(GOOGLE_ROUTER)
API_V1_PPT_ROUTER.include_router(CODEX_AUTH_ROUTER)
API_V1_PPT_ROUTER.include_router(PPTX_FONTS_ROUTER)
+API_V1_PPT_ROUTER.include_router(THEMES_ROUTER)
+API_V1_PPT_ROUTER.include_router(THEME_ROUTER)
diff --git a/servers/fastapi/models/theme_data.py b/servers/fastapi/models/theme_data.py
new file mode 100644
index 00000000..1383b338
--- /dev/null
+++ b/servers/fastapi/models/theme_data.py
@@ -0,0 +1,41 @@
+from pydantic import BaseModel
+from typing import Dict
+
+
+class ThemeData(BaseModel):
+ primary: str
+ background: str
+ card: str
+ stroke: str
+ background_text: str
+ primary_text: str
+ graph_0: str
+ graph_1: str
+ graph_2: str
+ graph_3: str
+ graph_4: str
+ graph_5: str
+ graph_6: str
+ graph_7: str
+ graph_8: str
+ graph_9: str
+
+
+class GeneratedColorPalette(BaseModel):
+ primary: str
+ background: str
+ accent_1: str
+ accent_2: str
+ text_1: str
+ text_2: str
+ primary_variations: Dict[str, str]
+ background_variations: Dict[str, str]
+ accent_1_variations: Dict[str, str]
+ accent_2_variations: Dict[str, str]
+ primary_lightness: float
+ background_lightness: float
+ accent_1_lightness: float
+ accent_2_lightness: float
+ text_1_lightness: float
+ text_2_lightness: float
+
diff --git a/servers/fastapi/utils/theme_utils.py b/servers/fastapi/utils/theme_utils.py
new file mode 100644
index 00000000..3838d7f7
--- /dev/null
+++ b/servers/fastapi/utils/theme_utils.py
@@ -0,0 +1,357 @@
+from __future__ import annotations
+
+import math
+import random
+from dataclasses import dataclass
+from typing import Dict, Optional
+
+from models.theme_data import GeneratedColorPalette
+
+IS_DARK_BELOW = 0.65
+BACKGROUND_RETRIES = 200
+TEXT_RETRIES = 200
+
+LIGHTNESS_VALUES: Dict[str, float] = {
+ "50": 0.97,
+ "100": 0.93,
+ "200": 0.86,
+ "300": 0.78,
+ "400": 0.70,
+ "500": 0.62,
+ "600": 0.54,
+ "700": 0.46,
+ "800": 0.38,
+ "900": 0.30,
+}
+
+
+@dataclass(frozen=True)
+class Oklch:
+ l: float # noqa: E741
+ c: float
+ h: float
+
+
+def _clamp(value: float, min_value: float = 0.0, max_value: float = 1.0) -> float:
+ return max(min_value, min(max_value, value))
+
+
+def _get_random_value(min_value: float, max_value: float) -> float:
+ return min_value + random.random() * (max_value - min_value)
+
+
+def _get_random_value_at_min_max_distance(
+ base_value: float,
+ min_value: float,
+ max_value: float,
+ min_distance: Optional[float] = None,
+ max_distance: Optional[float] = None,
+) -> float:
+ normalized_min_distance = max(0.0, min_distance or 0.0)
+ normalized_max_distance = max_distance if max_distance is not None else math.inf
+ min_dist = min(normalized_min_distance, normalized_max_distance)
+ max_dist = max(normalized_min_distance, normalized_max_distance)
+
+ lower_start = max(min_value, base_value - max_dist)
+ lower_end = min(max_value, base_value - min_dist)
+ upper_start = max(min_value, base_value + min_dist)
+ upper_end = min(max_value, base_value + max_dist)
+
+ lower_size = max(0.0, lower_end - lower_start)
+ upper_size = max(0.0, upper_end - upper_start)
+ total_size = lower_size + upper_size
+
+ if total_size <= 0:
+ return _get_random_value(min_value, max_value)
+
+ picker = random.random() * total_size
+ if picker < lower_size:
+ return _get_random_value(lower_start, lower_end)
+
+ return _get_random_value(upper_start, upper_end)
+
+
+def _srgb_to_linear(channel: float) -> float:
+ if channel <= 0.04045:
+ return channel / 12.92
+ return ((channel + 0.055) / 1.055) ** 2.4
+
+
+def _linear_to_srgb(channel: float) -> float:
+ if channel <= 0.0031308:
+ return 12.92 * channel
+ return 1.055 * (channel ** (1 / 2.4)) - 0.055
+
+
+def _oklch_to_srgb(color: Oklch) -> tuple[float, float, float]:
+ hue_rad = math.radians(color.h % 360)
+ a = color.c * math.cos(hue_rad)
+ b = color.c * math.sin(hue_rad)
+
+ l_ = (color.l + 0.3963377774 * a + 0.2158037573 * b) ** 3
+ m_ = (color.l - 0.1055613458 * a - 0.0638541728 * b) ** 3
+ s_ = (color.l - 0.0894841775 * a - 1.2914855480 * b) ** 3
+
+ r = 4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_
+ g = -1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_
+ b = -0.0041960863 * l_ - 0.7034186147 * m_ + 1.7076147010 * s_
+
+ return (
+ _clamp(_linear_to_srgb(r)),
+ _clamp(_linear_to_srgb(g)),
+ _clamp(_linear_to_srgb(b)),
+ )
+
+
+def _srgb_to_oklch(r: float, g: float, b: float) -> Oklch:
+ r_lin = _srgb_to_linear(r)
+ g_lin = _srgb_to_linear(g)
+ b_lin = _srgb_to_linear(b)
+
+ l_ = 0.4122214708 * r_lin + 0.5363325363 * g_lin + 0.0514459929 * b_lin
+ m_ = 0.2119034982 * r_lin + 0.6806995451 * g_lin + 0.1073969566 * b_lin
+ s_ = 0.0883024619 * r_lin + 0.2817188376 * g_lin + 0.6299787005 * b_lin
+
+ l_cbrt = math.copysign(abs(l_) ** (1 / 3), l_)
+ m_cbrt = math.copysign(abs(m_) ** (1 / 3), m_)
+ s_cbrt = math.copysign(abs(s_) ** (1 / 3), s_)
+
+ lightness = 0.2104542553 * l_cbrt + 0.7936177850 * m_cbrt - 0.0040720468 * s_cbrt
+ a = 1.9779984951 * l_cbrt - 2.4285922050 * m_cbrt + 0.4505937099 * s_cbrt
+ b = 0.0259040371 * l_cbrt + 0.7827717662 * m_cbrt - 0.8086757660 * s_cbrt
+
+ chroma = math.hypot(a, b)
+ hue = math.degrees(math.atan2(b, a)) % 360
+
+ return Oklch(l=lightness, c=chroma, h=hue)
+
+
+def _hex_to_oklch(hex_value: str) -> Oklch:
+ hex_value = hex_value.strip().lstrip("#")
+ if len(hex_value) != 6:
+ raise ValueError(f"Invalid hex color: {hex_value!r}")
+ r = int(hex_value[0:2], 16) / 255.0
+ g = int(hex_value[2:4], 16) / 255.0
+ b = int(hex_value[4:6], 16) / 255.0
+ return _srgb_to_oklch(r, g, b)
+
+
+def _format_hex(color: Oklch) -> str:
+ r, g, b = _oklch_to_srgb(color)
+ return "#{:02x}{:02x}{:02x}".format(
+ int(round(r * 255)),
+ int(round(g * 255)),
+ int(round(b * 255)),
+ )
+
+
+def _relative_luminance(color: Oklch) -> float:
+ r, g, b = _oklch_to_srgb(color)
+ r_lin = _srgb_to_linear(r)
+ g_lin = _srgb_to_linear(g)
+ b_lin = _srgb_to_linear(b)
+ return 0.2126 * r_lin + 0.7152 * g_lin + 0.0722 * b_lin
+
+
+def _wcag_contrast(a: Oklch, b: Oklch) -> float:
+ l1 = _relative_luminance(a)
+ l2 = _relative_luminance(b)
+ lighter = max(l1, l2)
+ darker = min(l1, l2)
+ return (lighter + 0.05) / (darker + 0.05)
+
+
+def _get_color_for_all_lightness_values(base_color: Oklch) -> Dict[str, str]:
+ colors: Dict[str, str] = {}
+ for name, value in LIGHTNESS_VALUES.items():
+ color = Oklch(l=value, c=base_color.c, h=base_color.h)
+ colors[name] = _format_hex(color)
+ return colors
+
+
+def _generate_primary_color() -> Oklch:
+ lightness = _get_random_value(0.0, 1.0)
+ chroma = _get_random_value(0.0, 0.4)
+ hue = _get_random_value(0.0, 360.0)
+ return Oklch(l=lightness, c=chroma, h=hue)
+
+
+def _generate_background_color(base_color: Oklch) -> Oklch:
+ for _ in range(BACKGROUND_RETRIES):
+ lightness = _get_random_value(0.0, 1.0)
+ chroma = _get_random_value(0.0, 0.4)
+ hue = _get_random_value(0.0, 360.0)
+ color = Oklch(l=lightness, c=chroma, h=hue)
+ if _wcag_contrast(color, base_color) >= 6:
+ return color
+
+ if base_color.l < IS_DARK_BELOW:
+ return Oklch(l=1.0, c=0.0, h=0.0)
+ return Oklch(l=0.0, c=0.0, h=0.0)
+
+
+def _generate_accent_color(base_color: Oklch, n: int) -> Oklch:
+ lightness = _get_random_value_at_min_max_distance(base_color.l, 0.0, 1.0, 0.0, 0.1)
+ chroma = _get_random_value_at_min_max_distance(base_color.c, 0.0, 0.4, 0.0, 0.4)
+ hue = _get_random_value_at_min_max_distance(
+ base_color.h if base_color.h is not None else 0.0,
+ 0.0,
+ 360.0,
+ n * 90.0,
+ (n + 1) * 90.0,
+ )
+ return Oklch(l=lightness, c=chroma, h=hue)
+
+
+def _generate_text_color(base_color: Oklch, text_type: str) -> Oklch:
+ is_base_dark = base_color.l < IS_DARK_BELOW
+
+ for _ in range(TEXT_RETRIES):
+ if text_type == "text_1":
+ lightness = (
+ _get_random_value(0.8, 1.0)
+ if is_base_dark
+ else _get_random_value(0.0, 0.2)
+ )
+ chroma = _get_random_value(0.0, 0.02)
+ elif text_type == "text_2":
+ lightness = (
+ _get_random_value(0.8, 1.0)
+ if is_base_dark
+ else _get_random_value(0.0, 0.2)
+ )
+ chroma = _get_random_value(0.0, 0.04)
+ else:
+ raise ValueError(f"Invalid text type: {text_type}")
+
+ hue = _get_random_value(0.0, 360.0)
+ color = Oklch(l=lightness, c=chroma, h=hue)
+
+ min_contrast = 6.0
+ max_contrast = None
+ contrast = _wcag_contrast(color, base_color)
+
+ if contrast >= min_contrast and (
+ max_contrast is None or contrast <= max_contrast
+ ):
+ return color
+
+ if base_color.l < IS_DARK_BELOW:
+ return Oklch(l=1.0 if text_type == "text_1" else 0.9, c=0.0, h=0.0)
+ return Oklch(l=0.0 if text_type == "text_1" else 0.1, c=0.0, h=0.0)
+
+
+def get_lightness_key_at_distance(
+ value: float,
+ min_distance: Optional[int] = None,
+ max_distance: Optional[int] = None,
+ prefer_dark: Optional[bool] = None,
+) -> str:
+ items = sorted(LIGHTNESS_VALUES.items(), key=lambda item: item[1])
+
+ nearest_index = 0
+ nearest_distance = abs(items[0][1] - value)
+ for index, (_, lightness) in enumerate(items[1:], start=1):
+ distance = abs(lightness - value)
+ if distance < nearest_distance or (
+ distance == nearest_distance and lightness < items[nearest_index][1]
+ ):
+ nearest_index = index
+ nearest_distance = distance
+
+ normalized_min = max(0, min_distance or 0)
+ normalized_max = max_distance if max_distance is not None else normalized_min
+ if normalized_max < normalized_min:
+ normalized_min, normalized_max = normalized_max, normalized_min
+
+ candidate_indices = []
+ for distance in range(normalized_min, normalized_max + 1):
+ lower_index = nearest_index - distance
+ upper_index = nearest_index + distance
+ if 0 <= lower_index < len(items):
+ candidate_indices.append(lower_index)
+ if upper_index != lower_index and 0 <= upper_index < len(items):
+ candidate_indices.append(upper_index)
+
+ if not candidate_indices:
+ return items[nearest_index][0]
+
+ if prefer_dark is True:
+ darker_candidates = [idx for idx in candidate_indices if idx <= nearest_index]
+ if darker_candidates:
+ return items[min(darker_candidates)][0]
+ return items[min(candidate_indices)][0]
+ if prefer_dark is False:
+ lighter_candidates = [idx for idx in candidate_indices if idx >= nearest_index]
+ if lighter_candidates:
+ return items[max(lighter_candidates)][0]
+ return items[max(candidate_indices)][0]
+
+ def distance_to_value(idx: int) -> float:
+ return abs(items[idx][1] - value)
+
+ closest_index = min(candidate_indices, key=lambda idx: (distance_to_value(idx), idx))
+ return items[closest_index][0]
+
+
+def generate_color_palette(
+ provided_primary: Optional[str] = None,
+ provided_background: Optional[str] = None,
+ provided_accent_1: Optional[str] = None,
+ provided_accent_2: Optional[str] = None,
+ provided_text_1: Optional[str] = None,
+ provided_text_2: Optional[str] = None,
+) -> GeneratedColorPalette:
+ primary = (
+ _hex_to_oklch(provided_primary) if provided_primary else _generate_primary_color()
+ )
+ background = (
+ _hex_to_oklch(provided_background)
+ if provided_background
+ else _generate_background_color(primary)
+ )
+ accent_1 = (
+ _hex_to_oklch(provided_accent_1)
+ if provided_accent_1
+ else _generate_accent_color(primary, 1)
+ )
+ accent_2 = (
+ _hex_to_oklch(provided_accent_2)
+ if provided_accent_2
+ else _generate_accent_color(primary, 2)
+ )
+ text_1 = (
+ _hex_to_oklch(provided_text_1)
+ if provided_text_1
+ else _generate_text_color(background, "text_1")
+ )
+ text_2 = (
+ _hex_to_oklch(provided_text_2)
+ if provided_text_2
+ else _generate_text_color(primary, "text_2")
+ )
+
+ primary_variations = _get_color_for_all_lightness_values(primary)
+ background_variations = _get_color_for_all_lightness_values(background)
+ accent_1_variations = _get_color_for_all_lightness_values(accent_1)
+ accent_2_variations = _get_color_for_all_lightness_values(accent_2)
+
+ return GeneratedColorPalette(
+ primary=_format_hex(primary),
+ background=_format_hex(background),
+ accent_1=_format_hex(accent_1),
+ accent_2=_format_hex(accent_2),
+ text_1=_format_hex(text_1),
+ text_2=_format_hex(text_2),
+ primary_variations=primary_variations,
+ background_variations=background_variations,
+ accent_1_variations=accent_1_variations,
+ accent_2_variations=accent_2_variations,
+ primary_lightness=primary.l,
+ background_lightness=background.l,
+ accent_1_lightness=accent_1.l,
+ accent_2_lightness=accent_2.l,
+ text_1_lightness=text_1.l,
+ text_2_lightness=text_2.l,
+ )
+
diff --git a/servers/nextjs/app/(presentation-generator)/(dashboard)/Components/DashboardSidebar.tsx b/servers/nextjs/app/(presentation-generator)/(dashboard)/Components/DashboardSidebar.tsx
index 1629603e..c7634ad9 100644
--- a/servers/nextjs/app/(presentation-generator)/(dashboard)/Components/DashboardSidebar.tsx
+++ b/servers/nextjs/app/(presentation-generator)/(dashboard)/Components/DashboardSidebar.tsx
@@ -1,7 +1,7 @@
"use client";
import React from "react";
-import { LayoutDashboard, Star, Brain, Settings } from "lucide-react";
+import { LayoutDashboard, Star, Brain, Settings, Palette } from "lucide-react";
import { usePathname } from "next/navigation";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -106,10 +106,21 @@ const DashboardSidebar = () => {
Templates
-
-
-
-
+
+
+
diff --git a/servers/nextjs/app/(presentation-generator)/(dashboard)/theme/components/ThemePanel/ColorPickerComponent.tsx b/servers/nextjs/app/(presentation-generator)/(dashboard)/theme/components/ThemePanel/ColorPickerComponent.tsx
new file mode 100644
index 00000000..e501ab23
--- /dev/null
+++ b/servers/nextjs/app/(presentation-generator)/(dashboard)/theme/components/ThemePanel/ColorPickerComponent.tsx
@@ -0,0 +1,66 @@
+"use client";
+import React from 'react'
+
+import { HexColorPicker, HexColorInput } from 'react-colorful'
+import { ThemeColors } from './types'
+
+interface ColorPickerComponentProps {
+ colorKey: keyof ThemeColors
+ currentColor: string
+ onColorChange: (colorKey: keyof ThemeColors, value: string) => void
+ showColorPicker: string | null
+ onShowColorPicker: (colorKey: string | null) => void,
+ label: string
+}
+
+export const ColorPickerComponent: React.FC = ({
+ colorKey,
+ currentColor,
+ onColorChange,
+ showColorPicker,
+ onShowColorPicker,
+ label
+}) => (
+
+ {label &&
+ {label}
+
}
+
+
{
+ e.stopPropagation()
+ onShowColorPicker(showColorPicker === colorKey ? null : colorKey)
+ }}
+ >
+ {showColorPicker === colorKey && (
+
e.stopPropagation()}
+ onMouseDown={(e) => e.stopPropagation()}
+ >
+
onColorChange(colorKey, color)}
+ />
+
+ onColorChange(colorKey, color)}
+ className="w-full px-2 py-1 text-sm border border-gray-300 rounded outline-none "
+ prefixed
+ />
+
+
+ )}
+
+
onColorChange(colorKey, e.target.value)}
+ placeholder="#000000"
+ />
+
+
+)
diff --git a/servers/nextjs/app/(presentation-generator)/(dashboard)/theme/components/ThemePanel/CustomTabEmpty.tsx b/servers/nextjs/app/(presentation-generator)/(dashboard)/theme/components/ThemePanel/CustomTabEmpty.tsx
new file mode 100644
index 00000000..de6f6f25
--- /dev/null
+++ b/servers/nextjs/app/(presentation-generator)/(dashboard)/theme/components/ThemePanel/CustomTabEmpty.tsx
@@ -0,0 +1,41 @@
+"use client";
+import { ArrowRight, Plus, Sparkle, Sparkles } from 'lucide-react'
+import { useRouter } from 'next/navigation'
+import React from 'react'
+
+const CustomTabEmpty = () => {
+ const router = useRouter()
+ return (
+ {
+ router.push('/theme?tab=new-theme')
+ }}
+ className='w-[305px] rounded-xl border border-[#EDEEEF] cursor-pointer'>
+
+

+
+
+
+
+
+
+
+
+
Build Theme
+
From colors fonts
+
+
+
+
+ )
+}
+
+export default CustomTabEmpty
\ No newline at end of file
diff --git a/servers/nextjs/app/(presentation-generator)/(dashboard)/theme/components/ThemePanel/FontCard.tsx b/servers/nextjs/app/(presentation-generator)/(dashboard)/theme/components/ThemePanel/FontCard.tsx
new file mode 100644
index 00000000..ef845603
--- /dev/null
+++ b/servers/nextjs/app/(presentation-generator)/(dashboard)/theme/components/ThemePanel/FontCard.tsx
@@ -0,0 +1,44 @@
+"use client";
+import React from 'react'
+import { Check } from 'lucide-react'
+
+interface FontCardProps {
+ font: any
+ isSelected: boolean
+ onSelect: (fontName: string) => void
+}
+
+export const FontCard: React.FC = ({ font, isSelected, onSelect }) => (
+ onSelect(font.name)}
+ >
+
+
+
+
+ {font.displayName}
+
+
+ ABC abc 123
+
+
+
+ Aa
+
+
+
+)
diff --git a/servers/nextjs/app/(presentation-generator)/(dashboard)/theme/components/ThemePanel/StepIndicator.tsx b/servers/nextjs/app/(presentation-generator)/(dashboard)/theme/components/ThemePanel/StepIndicator.tsx
new file mode 100644
index 00000000..76ca7281
--- /dev/null
+++ b/servers/nextjs/app/(presentation-generator)/(dashboard)/theme/components/ThemePanel/StepIndicator.tsx
@@ -0,0 +1,33 @@
+import React from 'react'
+
+interface StepIndicatorProps {
+ currentStep: number
+}
+
+const steps = [
+ { step: 1, label: 'Brand' },
+ { step: 2, label: 'Palette' },
+ { step: 3, label: 'Fonts' },
+ { step: 4, label: 'Logo' },
+]
+
+export const StepIndicator: React.FC = ({ currentStep }) => (
+
+ {steps.map(({ step, label }) => {
+ const isActive = currentStep === step
+ return (
+
+
+ Step-{step}
+
+ {label}
+
+ )
+ })}
+
+)
diff --git a/servers/nextjs/app/(presentation-generator)/(dashboard)/theme/components/ThemePanel/ThemeCard.tsx b/servers/nextjs/app/(presentation-generator)/(dashboard)/theme/components/ThemePanel/ThemeCard.tsx
new file mode 100644
index 00000000..9297b697
--- /dev/null
+++ b/servers/nextjs/app/(presentation-generator)/(dashboard)/theme/components/ThemePanel/ThemeCard.tsx
@@ -0,0 +1,200 @@
+"use client";
+import React, { useState } from 'react'
+import { AlertTriangle, Check, Copy, Trash } from 'lucide-react'
+import { Theme } from '@/app/(presentation-generator)/services/api/types'
+import ToolTip from '@/components/ToolTip'
+
+interface ThemeCardProps {
+ theme: Theme
+ onSelect: (theme: Theme) => void
+ onDelete: (themeId: string) => void
+ showDeleteButton?: boolean
+}
+
+export const ThemeCard: React.FC = ({ theme, onSelect, onDelete, showDeleteButton = true }) => {
+ if (!theme.data.colors['graph_0']) { return null }
+ const [showDeleteDialog, setShowDeleteDialog] = useState(false)
+ const [copied, setCopied] = useState(false)
+
+
+
+
+ return ( onSelect(theme)}
+
+ >
+ {showDeleteButton &&
}
+
+ {/* Delete Confirmation Dialog */}
+ {showDeleteDialog && (
+
{
+ e.stopPropagation()
+ setShowDeleteDialog(false)
+ }}
+ >
+
+
e.stopPropagation()}
+ >
+
+
+
Delete Theme?
+
+ You're about to delete "{theme.name}". This action cannot be undone.
+
+
+
+
+
+
+
+
+ )}
+
+
+
+
+

+
+
+
+
+
+ {theme.data.fonts.textFont.name}
+
+
+ {theme.company_name &&
+
+
+
+ {theme.company_name}
+
+ }
+ {theme.logo_url &&
+
+
+
+
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+ {theme.name}
+
+
+ Choose your preferences.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
)
+
+}
+
+
+
diff --git a/servers/nextjs/app/(presentation-generator)/(dashboard)/theme/components/ThemePanel/constants.ts b/servers/nextjs/app/(presentation-generator)/(dashboard)/theme/components/ThemePanel/constants.ts
new file mode 100644
index 00000000..95a4c088
--- /dev/null
+++ b/servers/nextjs/app/(presentation-generator)/(dashboard)/theme/components/ThemePanel/constants.ts
@@ -0,0 +1,205 @@
+
+
+export const FONT_OPTIONS: any[] = [
+ { name: 'Inter', displayName: 'Inter', cssUrl: 'https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap' },
+ { name: 'DM Sans', displayName: 'DM Sans', cssUrl: 'https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&display=swap' },
+ { name: 'Overpass', displayName: 'Overpass', cssUrl: 'https://fonts.googleapis.com/css2?family=Overpass:wght@100..900&display=swap' },
+ { name: 'Barlow', displayName: 'Barlow', cssUrl: 'https://fonts.googleapis.com/css2?family=Barlow:wght@100..900&display=swap' },
+ { name: 'Nunito', displayName: 'Nunito', cssUrl: 'https://fonts.googleapis.com/css2?family=Nunito:wght@200..1000&display=swap' },
+ { name: 'Lora', displayName: 'Lora', cssUrl: 'https://fonts.googleapis.com/css2?family=Lora:wght@400;500;600;700&display=swap' },
+ { name: 'Instrument Sans', displayName: 'Instrument Sans', cssUrl: 'https://fonts.googleapis.com/css2?family=Instrument+Sans:ital,wght@0,400..700;1,400..700&display=swap' },
+ { name: 'Roboto Slab', displayName: 'Roboto Slab', cssUrl: 'https://fonts.googleapis.com/css2?family=Roboto+Slab:wght@100..900&display=swap' },
+ { name: 'Montserrat', displayName: 'Montserrat', cssUrl: 'https://fonts.googleapis.com/css2?family=Montserrat:wght@100..900&display=swap' },
+ { name: 'Libre Baskerville', displayName: 'Libre Baskerville', cssUrl: 'https://fonts.googleapis.com/css2?family=Libre+Baskerville:wght@400;700&display=swap' },
+ { name: 'Prompt', displayName: 'Prompt', cssUrl: 'https://fonts.googleapis.com/css2?family=Prompt:wght@100..900&display=swap' },
+ { name: 'Inconsolata', displayName: 'Inconsolata', cssUrl: 'https://fonts.googleapis.com/css2?family=Inconsolata:wght@200..900&display=swap' },
+ { name: 'Fraunces', displayName: 'Fraunces', cssUrl: 'https://fonts.googleapis.com/css2?family=Fraunces:wght@300..900&display=swap' },
+ { name: 'Gelasio', displayName: 'Gelasio', cssUrl: 'https://fonts.googleapis.com/css2?family=Gelasio:wght@300..700&display=swap' },
+ { name: 'Raleway', displayName: 'Raleway', cssUrl: 'https://fonts.googleapis.com/css2?family=Raleway:wght@100..900&display=swap' },
+ { name: 'Kanit', displayName: 'Kanit', cssUrl: 'https://fonts.googleapis.com/css2?family=Kanit:wght@100..900&display=swap' },
+ { name: 'Corben', displayName: 'Corben', cssUrl: 'https://fonts.googleapis.com/css2?family=Corben:wght@400;700&display=swap' },
+ { name: 'Poppins', displayName: 'Poppins', cssUrl: 'https://fonts.googleapis.com/css2?family=Poppins:wght@100..900&display=swap' },
+ { name: 'Open Sans', displayName: 'Open Sans', cssUrl: 'https://fonts.googleapis.com/css2?family=Open+Sans:wght@300..800&display=swap' },
+ { name: 'Lato', displayName: 'Lato', cssUrl: 'https://fonts.googleapis.com/css2?family=Lato:wght@100..900&display=swap' },
+ { name: 'Source Sans Pro', displayName: 'Source Sans Pro', cssUrl: 'https://fonts.googleapis.com/css2?family=Source+Sans+Pro:wght@200..900&display=swap' },
+ { name: 'Playfair Display', displayName: 'Playfair Display', cssUrl: 'https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400..900&display=swap' },
+ { name: 'Roboto', displayName: 'Roboto', cssUrl: 'https://fonts.googleapis.com/css2?family=Roboto:wght@100..900&display=swap' }
+]
+
+export const DEFAULT_THEMES: any[] = [
+ {
+ id: "edge-yellow",
+ name: "Edge Yellow",
+ description: "Yellow and dark theme for professionalish and edge.",
+ logo: null,
+ logo_url: null,
+ company_name: null,
+
+ data: {
+ colors: {
+ primary: "#f5f547",
+ background: "#1f1f1f",
+ card: "#424242",
+ stroke: "#585858",
+ primary_text: "#161616",
+ background_text: "#f5f547",
+ graph_0: "#ffff54",
+ graph_1: "#f1f142",
+ graph_2: "#dada15",
+ graph_3: "#c1bf00",
+ graph_4: "#a8a600",
+ graph_5: "#908c00",
+ graph_6: "#797400",
+ graph_7: "#625c00",
+ graph_8: "#4d4500",
+ graph_9: "#382f00"
+ },
+ fonts: {
+ textFont: {
+ name: "Playfair Display",
+ url: "https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400..900&display=swap"
+ }
+ }
+ }
+ },
+ {
+ id: "light-rose",
+ name: "Light Rose",
+ description: "Rose background with punchy font",
+ logo: null,
+ logo_url: null,
+ company_name: null,
+
+ data: {
+ colors: {
+ "primary": "#030204",
+ background: "#f69c9c",
+ card: "#ffaeb4",
+ stroke: "#bf6a6b",
+ primary_text: "#bebebe",
+ background_text: "#030202",
+ graph_0: "#2f2c32",
+ graph_1: "#444147",
+ graph_2: "#5a565d",
+ graph_3: "#706d73",
+ graph_4: "#88848b",
+ graph_5: "#a09da4",
+ graph_6: "#b9b6bd",
+ graph_7: "#d3cfd6",
+ graph_8: "#eae6ed",
+ graph_9: "#f7f3fb"
+ },
+ fonts: {
+ textFont: {
+ name: "Overpass",
+ url: "https://fonts.googleapis.com/css2?family=Overpass:wght@100..900&display=swap"
+ }
+ }
+ }
+ },
+ {
+ id: "mint-blue",
+ name: "Mint Blue",
+ description: "Mint Greent with blue heading.",
+ logo: null,
+ logo_url: null,
+ company_name: null,
+
+ data: {
+ colors: {
+ primary: "#3b3172",
+ background: "#ffffff",
+ card: "#80e7cf",
+ stroke: "#d1d1d1",
+ primary_text: "#ffffff",
+ background_text: "#3b3172",
+ graph_0: "#003d2d",
+ graph_1: "#005341",
+ graph_2: "#006a57",
+ graph_3: "#00826d",
+ graph_4: "#2b9a85",
+ graph_5: "#4ab39d",
+ graph_6: "#65cdb6",
+ graph_7: "#80e7cf",
+ graph_8: "#98ffe6",
+ graph_9: "#a5fff4"
+ },
+ fonts: {
+ textFont: {
+ name: "Prompt",
+ url: "https://fonts.googleapis.com/css2?family=Prompt:wght@100..900&display=swap"
+ }
+ }
+ }
+ },
+ {
+ id: "professional-blue",
+ name: "Professional Blue",
+ description: "Clean and professional blue theme",
+ logo: null,
+ logo_url: null,
+ company_name: null,
+
+ data: {
+ colors: {
+ primary: "#161616",
+ background: "#ffffff",
+ card: "#dae6ff",
+ stroke: "#d1d1d1",
+ primary_text: "#eeeaea",
+ background_text: "#000000",
+ graph_0: "#2e2e2e",
+ graph_1: "#424242",
+ graph_2: "#585858",
+ graph_3: "#6f6f6f",
+ graph_4: "#868686",
+ graph_5: "#9e9e9e",
+ graph_6: "#b7b7b7",
+ graph_7: "#d1d1d1",
+ graph_8: "#e8e8e8",
+ graph_9: "#f5f5f5"
+ },
+ fonts: {
+ textFont: {
+ name: "Inter",
+ url: "https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap"
+ }
+ }
+ }
+ },
+ {
+ id: "professional-dark",
+ name: "Professional Dark",
+ description: "Clean and professional for dark corporate usage.",
+ logo: null,
+ logo_url: null,
+ company_name: null,
+
+ data: {
+ colors: {
+ primary: "#eff5f1",
+ background: "#050505",
+ card: "#424242",
+ stroke: "#585858",
+ primary_text: "#050505",
+ background_text: "#eff5f1",
+ graph_0: "#ebf6ff",
+ graph_1: "#dee8fa",
+ graph_2: "#c7d2e3",
+ graph_3: "#aeb8c9",
+ graph_4: "#959fb0",
+ graph_5: "#7d8797",
+ graph_6: "#666f7f",
+ graph_7: "#505867",
+ graph_8: "#3a4351",
+ graph_9: "#262e3c"
+ },
+ fonts: {
+ textFont: {
+ name: "Instrument Sans",
+ url: "https://fonts.googleapis.com/css2?family=Instrument+Sans:ital,wght@0,400..700;1,400..700&display=swap"
+ }
+ }
+ }
+ }
+]
\ No newline at end of file
diff --git a/servers/nextjs/app/(presentation-generator)/(dashboard)/theme/components/ThemePanel/index.tsx b/servers/nextjs/app/(presentation-generator)/(dashboard)/theme/components/ThemePanel/index.tsx
new file mode 100644
index 00000000..35a22c57
--- /dev/null
+++ b/servers/nextjs/app/(presentation-generator)/(dashboard)/theme/components/ThemePanel/index.tsx
@@ -0,0 +1,1049 @@
+'use client'
+
+import React, { useState, useEffect, useRef } from 'react'
+import { Button } from '@/components/ui/button'
+import { Label } from '@/components/ui/label'
+import {
+ Loader2,
+ SquarePen,
+ RefreshCcw,
+ ChevronRight,
+ Plus
+} from 'lucide-react'
+import { Sheet, SheetContent } from '@/components/ui/sheet'
+
+
+import { ThemeColors } from './types'
+import { FONT_OPTIONS, DEFAULT_THEMES } from './constants'
+
+
+import { StepIndicator } from './StepIndicator'
+import { ColorPickerComponent } from './ColorPickerComponent'
+import { FontCard } from './FontCard'
+import { ThemeCard } from './ThemeCard'
+
+import { toast } from 'sonner'
+import { Theme, ThemeParams } from '@/app/(presentation-generator)/services/api/types'
+import { ImagesApi } from '@/app/(presentation-generator)/services/api/images'
+import { Input } from '@/components/ui/input'
+import { getTemplatesByTemplateName } from '@/app/presentation-templates'
+import { useSearchParams } from 'next/navigation'
+import CustomTabEmpty from './CustomTabEmpty'
+import ThemeApi from '@/app/(presentation-generator)/services/api/theme'
+import { useFontLoader } from '@/app/(presentation-generator)/hooks/useFontLoad'
+
+// Fallback theme used before defaults are loaded from API (unified Theme type)
+const FALLBACK_THEME: Theme = {
+ id: 'standard',
+ name: 'Standard',
+ description: 'Standard theme',
+ user: 'system',
+ logo: '',
+ logo_url: '',
+ data: {
+ colors: {
+ 'primary': '#2563eb',
+ 'background': '#ffffff',
+ 'card': '#f8fafc',
+ 'stroke': '#e5e7eb',
+ 'primary_text': '#1e293b',
+ 'background_text': '#475569',
+ 'graph_0': '#2563eb',
+ 'graph_1': '#1d4ed8',
+ 'graph_2': '#1e40af',
+ 'graph_3': '#1e40af',
+ 'graph_4': '#1e40af',
+ 'graph_5': '#1e40af',
+ 'graph_6': '#1e40af',
+ 'graph_7': '#1e40af',
+ 'graph_8': '#1e40af',
+ 'graph_9': '#1e40af',
+ },
+ fonts: {
+ textFont: { name: 'Inter', url: 'https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap' },
+ },
+ },
+}
+const ThemePanel: React.FC = () => {
+ const searchParams = useSearchParams()
+
+
+ const [selectedTheme, setSelectedTheme] = useState(FALLBACK_THEME)
+ const [tab, setTab] = useState<'custom' | 'default'>('default')
+
+ const [customColors, setCustomColors] = useState(FALLBACK_THEME.data.colors)
+ const [customFonts, setCustomFonts] = useState<{ textFont: { name: string, url: string } }>(FALLBACK_THEME.data.fonts)
+ const [customBrandLogo, setCustomBrandLogo] = useState(null)
+ const [customBrandLogoId, setCustomBrandLogoId] = useState(null)
+ const [isLogoUploading, setIsLogoUploading] = useState(false)
+ const [isFontUploading, setIsFontUploading] = useState(false)
+ const [customThemes, setCustomThemes] = useState([])
+ const [defaultThemes, setDefaultThemes] = useState([])
+ const [showColorPicker, setShowColorPicker] = useState(null)
+ const [isSheetOpen, setIsSheetOpen] = useState(false)
+ const [currentStep, setCurrentStep] = useState(1)
+ const [themeCompanyName, setThemeCompanyName] = useState('')
+ const [isNewTheme, setIsNewTheme] = useState(false)
+ const [userFonts, setUserFonts] = useState<{ fonts: { name: string, url: string }[] }>({ fonts: [] })
+
+ const previewContainerRef = useRef(null)
+ const slideContainerRef = useRef(null)
+ const [slideContainerWidth, setSlideContainerWidth] = useState(0)
+
+ // Calculate scale dynamically based on container width
+ const slideScale = () => {
+ const BASE_WIDTH = 1280
+ const SCROLLBAR_WIDTH = 8 // account for scrollbar
+ if (!slideContainerWidth) return 0.68 // fallback
+ const availableWidth = slideContainerWidth - SCROLLBAR_WIDTH
+ return availableWidth / BASE_WIDTH
+ }
+
+ useEffect(() => {
+ const el = slideContainerRef.current
+ if (!el) return
+
+ const ro = new ResizeObserver(() => {
+ setSlideContainerWidth(el.clientWidth)
+ })
+ ro.observe(el)
+ setSlideContainerWidth(el.clientWidth)
+
+ return () => ro.disconnect()
+ }, [isSheetOpen, slideContainerRef])
+
+
+
+ const handleCloseSheet = (open: boolean) => {
+ setIsSheetOpen(false)
+ if (!open) {
+ window.history.pushState({}, '', '/theme')
+ }
+ }
+
+ // Initialize theme on component mount
+ useEffect(() => {
+ applyTheme(selectedTheme)
+ }, [])
+
+ // Load custom themes from API and built-in themes from local constants
+ useEffect(() => {
+
+ const loadCustomThemes = async () => {
+ try {
+ const apiThemes = await ThemeApi.getThemes()
+ setCustomThemes(apiThemes)
+ const fonts = apiThemes.map(theme => theme.data.fonts.textFont)
+
+ const fontMap = fonts.map(font => ({ name: font.name, url: font.url }))
+ fontMap.forEach(font => {
+ useFontLoader({ [font.name]: font.url })
+ })
+ } catch (error: any) {
+ console.error('Failed to load custom themes', error)
+ toast.error(error?.message || 'Failed to load custom themes')
+ }
+ }
+ const loadUserFonts = async () => {
+ try {
+ const userFonts = await ThemeApi.getUserFonts()
+ setUserFonts(userFonts)
+ } catch (error: any) {
+ console.error('Failed to load user fonts', error)
+ toast.error(error?.message || 'Failed to load user fonts')
+ }
+ }
+ loadUserFonts()
+ const loadDefaultThemes = () => {
+ const localDefaults: Theme[] = DEFAULT_THEMES.map((theme) => ({
+ ...theme,
+ user: 'system',
+ logo: theme.logo ?? '',
+ logo_url: theme.logo_url ?? '',
+ company_name: theme.company_name ?? '',
+ }))
+
+ setDefaultThemes(localDefaults)
+
+ // If selected theme is still fallback, set first default
+ if (localDefaults.length > 0 && selectedTheme.id === FALLBACK_THEME.id) {
+ const first = localDefaults[0]
+ setSelectedTheme(first)
+ setCustomColors(first.data.colors)
+ setCustomFonts(first.data.fonts)
+ setCustomBrandLogo(first.logo_url || null)
+ setCustomBrandLogoId((first as any).logo || '')
+ applyTheme(first)
+ }
+ }
+ loadCustomThemes()
+ loadDefaultThemes()
+ }, [])
+
+
+ useEffect(() => {
+ const updatedTheme: Theme = {
+ ...selectedTheme,
+ logo_url: customBrandLogo || selectedTheme.logo_url,
+ company_name: themeCompanyName || selectedTheme.company_name,
+ data: {
+ ...selectedTheme.data,
+ colors: customColors,
+ fonts: customFonts,
+ },
+ }
+ applyTheme(updatedTheme)
+ }, [customColors, customFonts, customBrandLogo, selectedTheme])
+
+ // Reset custom values only when the selected theme ID changes
+ useEffect(() => {
+ if (selectedTheme) {
+ setCustomColors(selectedTheme.data.colors)
+ setCustomFonts(selectedTheme.data.fonts)
+ setCustomBrandLogo(selectedTheme.logo_url || '')
+ setCustomBrandLogoId((selectedTheme as any).logo || '')
+
+ }
+ }, [selectedTheme.id])
+
+
+
+ const template = getTemplatesByTemplateName('neo-general')
+ const applyTheme = (theme: Theme) => {
+ const cssVariables = {
+ '--primary-color': theme.data.colors['primary'],
+ '--background-color': theme.data.colors['background'],
+ '--card-color': theme.data.colors['card'],
+ '--stroke': theme.data.colors['stroke'],
+ '--primary-text': theme.data.colors['primary_text'],
+ '--background-text': theme.data.colors['background_text'],
+ '--graph-0': theme.data.colors['graph_0'],
+ '--graph-1': theme.data.colors['graph_1'],
+ '--graph-2': theme.data.colors['graph_2'],
+ '--graph-3': theme.data.colors['graph_3'],
+ '--graph-4': theme.data.colors['graph_4'],
+ '--graph-5': theme.data.colors['graph_5'],
+ '--graph-6': theme.data.colors['graph_6'],
+ '--graph-7': theme.data.colors['graph_7'],
+ '--graph-8': theme.data.colors['graph_8'],
+ '--graph-9': theme.data.colors['graph_9'],
+ }
+
+
+
+ // Apply theme to preview container
+ if (slideContainerRef.current) {
+
+ Object.entries(cssVariables).forEach(([key, value]) => {
+ slideContainerRef.current!.style.setProperty(key, value)
+ })
+
+ // Apply fonts to preview container
+ slideContainerRef.current!.style.setProperty('font-family', `"${theme.data.fonts.textFont.name}"`)
+ slideContainerRef.current!.style.setProperty('--heading-font-family', `"${theme.data.fonts.textFont.name}"`)
+ // Load font
+ useFontLoader({ [theme.data.fonts.textFont.name]: theme.data.fonts.textFont.url })
+ }
+ }
+
+ const handleThemeSelect = (theme: Theme) => {
+ setIsNewTheme(false)
+ setSelectedTheme(theme)
+ setCustomColors(theme.data.colors)
+ setCustomFonts(theme.data.fonts)
+ setCustomBrandLogo(theme.logo_url || '')
+ setIsSheetOpen(true)
+ setCurrentStep(1)
+
+ setThemeCompanyName(theme.company_name || '')
+ applyTheme(theme)
+ }
+
+ const handleColorChange = (colorKey: keyof ThemeColors, value: string) => {
+ let validValue = value
+ if (value && !value.startsWith('#')) {
+ validValue = `#${value}`
+ }
+
+ const newColors = { ...customColors, [colorKey]: validValue }
+ setCustomColors(newColors)
+ }
+
+ const handleFontSelect = (fontName: string, url: string) => {
+ setCustomFonts({ textFont: { name: fontName, url: url } })
+ }
+
+ const handleBrandLogoUpload = async (file: File) => {
+ try {
+ setIsLogoUploading(true)
+ const uploaded = await ImagesApi.uploadImage(file)
+ setCustomBrandLogo(uploaded.path)
+ setCustomBrandLogoId(uploaded.id)
+ } catch (error: any) {
+ console.error('Failed to upload logo', error)
+ toast.error(error?.message || 'Failed to upload logo')
+ } finally {
+ setIsLogoUploading(false)
+ }
+ }
+
+ const generateTheme = async ({ primary, background }: { primary?: string, background?: string }): Promise => {
+ const generatedTheme = await ThemeApi.generateTheme({ primary, background })
+ return {
+ 'primary': generatedTheme.primary,
+ 'background': generatedTheme.background,
+ 'card': generatedTheme.card,
+ 'stroke': generatedTheme.stroke,
+ 'primary_text': generatedTheme['primary_text'],
+ 'background_text': generatedTheme['background_text'],
+ 'graph_0': generatedTheme['graph_0'],
+ 'graph_1': generatedTheme['graph_1'],
+ 'graph_2': generatedTheme['graph_2'],
+ 'graph_3': generatedTheme['graph_3'],
+ 'graph_4': generatedTheme['graph_4'],
+ 'graph_5': generatedTheme['graph_5'],
+ 'graph_6': generatedTheme['graph_6'],
+ 'graph_7': generatedTheme['graph_7'],
+ 'graph_8': generatedTheme['graph_8'],
+ 'graph_9': generatedTheme['graph_9'],
+ }
+ }
+
+ const createNewCustomTheme = async () => {
+ setIsNewTheme(true)
+ const newTheme: Theme = {
+ id: `custom-${Date.now()}`,
+ name: 'New Custom Theme',
+ description: 'Start with a blank canvas',
+ user: 'local',
+ logo: '',
+ logo_url: '',
+ company_name: '',
+ data: {
+ colors: {
+ 'primary': '#0000c3',
+ 'background': '#f1fff3',
+ 'card': '#deece1',
+ 'stroke': '#c8d5ca',
+ 'primary_text': '#f1f1f1',
+ 'background_text': '#030101',
+ 'graph_0': '#7eeeff',
+ 'graph_1': '#70e0ff',
+ 'graph_2': '#58c7ff',
+ 'graph_3': '#3cabff',
+ 'graph_4': '#198fff',
+ 'graph_5': '#0073ff',
+ 'graph_6': '#0056ff',
+ 'graph_7': '#0036ed',
+ 'graph_8': '#0000d0',
+ 'graph_9': '#0000b4',
+ },
+ fonts: {
+ textFont: { name: 'Inter', url: 'https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap' },
+ }
+ }
+ }
+
+ const generatedColors = await generateTheme({})
+
+
+ const theme = {
+ ...newTheme,
+ data: {
+ ...newTheme.data,
+ colors: generatedColors,
+
+ }
+ }
+ setSelectedTheme(theme)
+ setCustomColors(theme.data.colors)
+ setCustomFonts(theme.data.fonts)
+ setCustomBrandLogo('')
+ setIsSheetOpen(true)
+ setCurrentStep(1)
+
+ setThemeCompanyName('')
+ applyTheme(theme)
+ }
+
+ const refeshTheme = async ({ primary, background }: { primary?: string, background?: string }) => {
+ const generatedTheme = await generateTheme({ primary, background })
+ setCustomColors(generatedTheme)
+ }
+ const saveAsCustom = async () => {
+ // If existing persisted custom theme, update via API (non-system and not a local draft)
+ if (selectedTheme.user && selectedTheme.user !== 'system' && !selectedTheme.id.startsWith('custom-')) {
+ ; (async () => {
+ try {
+ const params: ThemeParams = {
+ id: selectedTheme.id,
+ name: selectedTheme.name,
+ description: selectedTheme.description,
+ logo: customBrandLogoId || null,
+ logo_url: customBrandLogo || null,
+ company_name: themeCompanyName || null,
+ data: {
+ colors: customColors,
+ fonts: customFonts,
+ }
+ }
+ const updated = await ThemeApi.updateTheme(params)
+ setCustomThemes(customThemes.map(t => t.id === updated.id ? updated : t))
+ setSelectedTheme(updated)
+ setIsSheetOpen(false)
+ toast.success('Theme updated')
+ } catch (error: any) {
+ console.error('Failed to update theme', error)
+ toast.error(error?.message || 'Failed to update theme')
+ }
+ })()
+ return
+ }
+ try {
+ const params: ThemeParams = {
+ name: selectedTheme.name,
+ description: selectedTheme.description || `Custom version of ${selectedTheme.name}`,
+ logo: customBrandLogoId || null,
+ logo_url: customBrandLogo || null,
+ company_name: themeCompanyName || null,
+ data: {
+ colors: customColors,
+ fonts: customFonts,
+ }
+ }
+ const created = await ThemeApi.createTheme(params)
+ setCustomThemes([...customThemes, created])
+ setSelectedTheme(created)
+ setIsSheetOpen(false)
+
+
+ window.history.pushState({}, '', '/theme')
+ toast.success('Theme saved')
+ } catch (error: any) {
+ console.error('Failed to save theme', error)
+ toast.error(error?.message || 'Failed to save theme')
+ }
+ }
+
+ const handleClickOutside = () => {
+ setShowColorPicker(null)
+ }
+ const handleDelete = async (themeId: string) => {
+ await ThemeApi.deleteTheme(themeId)
+ setCustomThemes(customThemes.filter(theme => theme.id !== themeId))
+ toast.success("Theme deleted successfully")
+ }
+ const handleCustomFontChange = async (fontFile: File) => {
+ try {
+ setIsFontUploading(true)
+ const { name, url } = await ThemeApi.uploadFont(fontFile)
+ setCustomFonts({
+ textFont: {
+ name: name,
+ url: url,
+ }
+ })
+ // Add the newly uploaded font to userFonts if not already present
+ if (!userFonts.fonts.find(f => f.name === name)) {
+ setUserFonts(prev => ({
+ fonts: [...prev.fonts, { name, url }]
+ }))
+ }
+ toast.success(`Font "${name}" uploaded successfully`)
+ } catch (error: any) {
+ console.error('Failed to upload font', error)
+ toast.error(error?.message || 'Failed to upload font')
+ } finally {
+ setIsFontUploading(false)
+ }
+ }
+ const renderColorStep = (step: number) => (
+
+
+
+
+
+
+
+ {step === 2 &&
Brand Colors
}
+
+
+
+
+
+ {step === 2 &&
}
+ {step === 2 &&
+
+
}
+ {step === 2 &&
+
Graph/Chart Colors
+
+
+
+
+
+
+
+
+
+
+
+
+
+
}
+
+
+
+
+ )
+
+ const renderFontStep = () => (
+
+
+
+
+
+
+ {/* Upload Custom Font */}
+
+
Upload Custom Font
+
{
+ if (!isFontUploading) {
+ document.getElementById('font-upload')?.click()
+ }
+ }}
+ role="button"
+ tabIndex={0}
+ >
+ {isFontUploading ? (
+
+
+
+
+
+
Uploading font...
+
Please wait
+
+
+ ) : (
+
+
+
+
Upload Font File
+
.ttf, .otf, .woff, .woff2
+
+
+
+ )}
+
+
{
+ const file = e.target.files?.[0]
+ if (file) {
+ await handleCustomFontChange(file)
+ }
+ }}
+ />
+
+
+ {/* User's Uploaded Fonts */}
+ {userFonts.fonts.length > 0 && (
+
+
Your Uploaded Fonts
+
+ {userFonts.fonts?.map((font) => (
+ handleFontSelect(font.name, font.url)}
+ />
+ ))}
+
+
+ )}
+
+ {/* Preset Fonts */}
+
+
Pre-Sets
+
+ {FONT_OPTIONS.map((font) => (
+ handleFontSelect(font.name, font.cssUrl)}
+ />
+ ))}
+
+
+
+ )
+
+ const renderLogoStep = () => (
+
+
+
+
+ setThemeCompanyName(e.target.value)}
+ />
+
+
+
+
{
+
+ e.stopPropagation()
+ document.getElementById('logo-upload')?.click()
+ }}
+
+ role="button"
+ tabIndex={0}
+ >
+
+
+ {isLogoUploading ? (
+
+ ) : customBrandLogo ? (
+
+

+
+
+ ) : (
+ <>
+
+
+
+ {
+ const file = e.target.files?.[0]
+ if (file) {
+ await handleBrandLogoUpload(file)
+ }
+ }}
+ />
+
+
+ >
+ )}
+
+
+
+ )
+
+
+ // LOOK for new-theme in the url
+ useEffect(() => {
+ const tab = searchParams.get('tab')
+ if (tab === 'new-theme') {
+ createNewCustomTheme()
+
+ }
+ }, [searchParams])
+
+
+ return (
+
+
+
+ Themes
+
+ {/* Tabs */}
+
+
+
+
+
+ {/* Built-in Themes */}
+
+ {tab === 'default' &&
+ {
+ defaultThemes.map((theme) => (
+
+ ))
+ }
+
+
+
}
+ {/* Custom Themes Section */}
+ {tab === 'custom' && customThemes.length > 0 && (
+
+
+ {customThemes.map((theme) => (
+
+ ))}
+
+
+ )}
+
+ {tab === 'custom' && customThemes.length === 0 && (
+
+ )}
+
+ {/* Theme Editor Sheet */}
+
+
+
+
+
+ {/* Left side - Editor */}
+
+
+ setSelectedTheme({ ...selectedTheme, name: e.target.value })}>
+
+
+ {
+ document.getElementById('theme-name')?.focus()
+ }} />
+
+
+
+
+ {currentStep === 1 && renderColorStep(currentStep)}
+ {currentStep === 2 && renderColorStep(currentStep)}
+ {currentStep === 3 && renderFontStep()}
+ {currentStep === 4 && renderLogoStep()}
+
+
+
+
+ {currentStep > 1 && }
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Right side - Live Preview */}
+
{
+ // don't assign to .current to avoid readonly error; just apply when available
+ if (el) {
+ (previewContainerRef as React.MutableRefObject
).current = el
+ const updatedTheme: Theme = {
+ ...selectedTheme,
+ logo_url: customBrandLogo || selectedTheme.logo_url,
+ data: {
+ ...selectedTheme.data,
+ colors: customColors,
+ fonts: customFonts,
+ },
+ }
+ applyTheme(updatedTheme)
+ setSlideContainerWidth(slideContainerRef.current?.clientWidth || 0)
+ }
+ }}
+ // ref={previewContainerRef}
+ className=" w-full p-3 bg-gray-50">
+
+
+ {template && template.map((layout) => {
+ const {
+ component: LayoutComponent,
+ sampleData,
+
+ } = layout;
+ return (
+
+ )
+ })}
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default ThemePanel
+
+// No mapping helpers needed: using unified API Theme type everywhere
diff --git a/servers/nextjs/app/(presentation-generator)/(dashboard)/theme/components/ThemePanel/types.ts b/servers/nextjs/app/(presentation-generator)/(dashboard)/theme/components/ThemePanel/types.ts
new file mode 100644
index 00000000..14433667
--- /dev/null
+++ b/servers/nextjs/app/(presentation-generator)/(dashboard)/theme/components/ThemePanel/types.ts
@@ -0,0 +1,20 @@
+export interface ThemeColors {
+ 'primary': string
+ 'background': string
+ 'card': string
+ 'stroke': string
+ 'primary_text': string
+ 'background_text': string
+ 'graph_0': string
+ 'graph_1': string
+ 'graph_2': string
+ 'graph_3': string
+ 'graph_4': string
+ 'graph_5': string
+ 'graph_6': string
+ 'graph_7': string
+ 'graph_8': string
+ 'graph_9': string
+}
+
+
diff --git a/servers/nextjs/app/(presentation-generator)/(dashboard)/theme/components/ThemePanel/utils.ts b/servers/nextjs/app/(presentation-generator)/(dashboard)/theme/components/ThemePanel/utils.ts
new file mode 100644
index 00000000..9aadca9a
--- /dev/null
+++ b/servers/nextjs/app/(presentation-generator)/(dashboard)/theme/components/ThemePanel/utils.ts
@@ -0,0 +1,10 @@
+export const loadGoogleFont = (fontFamily: string) => {
+ // Check if font is already loaded
+ const existingLink = document.querySelector(`link[href*="${fontFamily.replace(' ', '+')}"]`)
+ if (existingLink) return
+
+ const link = document.createElement('link')
+ link.href = `https://fonts.googleapis.com/css2?family=${fontFamily.replace(' ', '+')}:wght@300;400;500;600;700&display=swap`
+ link.rel = 'stylesheet'
+ document.head.appendChild(link)
+}
diff --git a/servers/nextjs/app/(presentation-generator)/(dashboard)/theme/loading.tsx b/servers/nextjs/app/(presentation-generator)/(dashboard)/theme/loading.tsx
new file mode 100644
index 00000000..39a7bb57
--- /dev/null
+++ b/servers/nextjs/app/(presentation-generator)/(dashboard)/theme/loading.tsx
@@ -0,0 +1,54 @@
+import { Skeleton } from '@/components/ui/skeleton'
+
+const ThemeCardSkeleton = () => (
+
+ {/* Preview area */}
+
+ {/* Top badges */}
+
+
+
+
+ {/* Card preview */}
+
+
+ {/* Bottom info */}
+
+
+)
+
+const Loading = () => {
+ return (
+
+ {/* Tabs skeleton */}
+
+
+ {/* Theme cards grid */}
+
+ {Array.from({ length: 4 }).map((_, idx) => (
+
+ ))}
+
+
+ )
+}
+
+export default Loading
+
diff --git a/servers/nextjs/app/(presentation-generator)/(dashboard)/theme/page.tsx b/servers/nextjs/app/(presentation-generator)/(dashboard)/theme/page.tsx
new file mode 100644
index 00000000..d7144f76
--- /dev/null
+++ b/servers/nextjs/app/(presentation-generator)/(dashboard)/theme/page.tsx
@@ -0,0 +1,9 @@
+import React from 'react'
+import ThemePanel from './components/ThemePanel'
+const page = () => {
+ return (
+
+ )
+}
+
+export default page
\ No newline at end of file
diff --git a/servers/nextjs/app/(presentation-generator)/hooks/useFontLoad.tsx b/servers/nextjs/app/(presentation-generator)/hooks/useFontLoad.tsx
new file mode 100644
index 00000000..7910bbda
--- /dev/null
+++ b/servers/nextjs/app/(presentation-generator)/hooks/useFontLoad.tsx
@@ -0,0 +1,36 @@
+
+export const useFontLoader = (fonts: Record) => {
+ const injectFonts = () => {
+ if (typeof document === 'undefined' || !fonts || typeof fonts !== 'object') return;
+
+ const ensureStylesheetLink = (href: string) => {
+ const existing = document.querySelector(`link[rel="stylesheet"][data-font-url="${href}"]`);
+ if (existing) return;
+ const link = document.createElement('link');
+ link.rel = 'stylesheet';
+ link.setAttribute('data-font-url', href);
+ link.href = href;
+ document.head.appendChild(link);
+ };
+
+ const ensureFontFaceStyle = (name: string, srcUrl: string) => {
+ const existing = document.querySelector(`style[data-font-url="${srcUrl}"]`);
+ if (existing) return;
+ const styleEl = document.createElement('style');
+ styleEl.setAttribute('data-font-url', srcUrl);
+ styleEl.textContent = `@font-face {\n font-family: '${name}';\n src: url('${srcUrl}');\n font-style: normal;\n font-display: swap;\n}`;
+ document.head.appendChild(styleEl);
+ };
+
+ Object.entries(fonts).forEach(([name, url]) => {
+ if (!name || !url) return;
+ const isCss = /\.css(\?|$)/i.test(url) || /fonts\.googleapis\.com/.test(url);
+ if (isCss) {
+ ensureStylesheetLink(url);
+ } else {
+ ensureFontFaceStyle(name, url);
+ }
+ });
+ };
+ injectFonts();
+};
\ No newline at end of file
diff --git a/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationHeader.tsx b/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationHeader.tsx
index 2b9b4c1a..ca5d86e8 100644
--- a/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationHeader.tsx
+++ b/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationHeader.tsx
@@ -7,12 +7,11 @@ import {
Undo2,
RotateCcw,
ArrowRightFromLine,
- ExternalLink,
- MoveUpRight,
+
ArrowUpRight,
} from "lucide-react";
-import React, { useState } from "react";
+import React, { useEffect, useState } from "react";
import { useRouter, usePathname } from "next/navigation";
import {
Popover,
@@ -35,6 +34,9 @@ import { clearPresentationData } from "@/store/slices/presentationGeneration";
import { clearHistory } from "@/store/slices/undoRedoSlice";
import { Separator } from "@/components/ui/separator";
import ThemeSelector from "./ThemeSelector";
+import { DEFAULT_THEMES } from "../../(dashboard)/theme/components/ThemePanel/constants";
+import ThemeApi from "../../services/api/theme";
+import { Theme } from "../../services/api/types";
const PresentationHeader = ({
presentation_id,
@@ -48,6 +50,8 @@ const PresentationHeader = ({
const [open, setOpen] = useState(false);
const router = useRouter();
const [isExporting, setIsExporting] = useState(false);
+ const [themes, setThemes] = useState([]);
+
const pathname = usePathname();
const dispatch = useDispatch();
@@ -56,6 +60,22 @@ const PresentationHeader = ({
(state: RootState) => state.presentationGeneration
);
+ useEffect(() => {
+ const load = async () => {
+ try {
+ const [customThemes] = await Promise.all([
+ ThemeApi.getThemes(),
+ ]);
+ setThemes([...customThemes, ...DEFAULT_THEMES]);
+ } catch (e: any) {
+ toast.error(e?.message || "Failed to load themes");
+ }
+ };
+ if (themes.length === 0) {
+ load();
+ }
+ }, []);
+
const { onUndo, onRedo, canUndo, canRedo } = usePresentationUndoRedo();
const get_presentation_pptx_model = async (id: string): Promise => {
@@ -188,6 +208,7 @@ const PresentationHeader = ({
+
return (
<>
@@ -197,7 +218,7 @@ const PresentationHeader = ({
{isPresentationSaving &&
}
- {/*
*/}
+
diff --git a/servers/nextjs/app/(presentation-generator)/presentation/components/ThemeSelector.tsx b/servers/nextjs/app/(presentation-generator)/presentation/components/ThemeSelector.tsx
index 65ecec72..55e0d7c0 100644
--- a/servers/nextjs/app/(presentation-generator)/presentation/components/ThemeSelector.tsx
+++ b/servers/nextjs/app/(presentation-generator)/presentation/components/ThemeSelector.tsx
@@ -1,13 +1,12 @@
"use client";
import React, { useState } from 'react'
-// import { Theme } from '@/app/(presentation-generator)/services/api/types'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Palette } from 'lucide-react';
import { useDispatch } from 'react-redux';
-// import { updateTheme } from '@/store/slices/presentationGeneration';
+import { updateTheme } from '@/store/slices/presentationGeneration';
import { useRouter } from 'next/navigation';
-import { useFontLoader } from '../../hooks/useFontLoader';
+import { useFontLoader } from '../../hooks/useFontLoad';
const ThemeSelector = ({ presentation_id, current_theme, themes: allThemes }: { presentation_id: string, current_theme: any, themes: any[] }) => {
const [currentTheme, setCurrentTheme] = useState
(current_theme)
const dispatch = useDispatch()
@@ -40,13 +39,13 @@ const ThemeSelector = ({ presentation_id, current_theme, themes: allThemes }: {
Object.entries(cssVariables).forEach(([key, value]) => {
element.style.setProperty(key, value)
})
- // useFontLoader({ [theme.data.fonts.textFont.name]: theme.data.fonts.textFont.url })
+ useFontLoader({ [theme.data.fonts.textFont.name]: theme.data.fonts.textFont.url })
// Apply fonts to preview container
element.style.setProperty('font-family', `"${theme.data.fonts.textFont.name}"`)
element.style.setProperty('--heading-font-family', `"${theme.data.fonts.textFont.name}"`)
- // dispatch(updateTheme(theme))
+ dispatch(updateTheme(theme))
}
const clearTheme = () => {
const element = document.getElementById('presentation-slides-wrapper')
@@ -71,7 +70,7 @@ const ThemeSelector = ({ presentation_id, current_theme, themes: allThemes }: {
const resetTheme = async () => {
clearTheme();
- // dispatch(updateTheme({} as any))
+ dispatch(updateTheme({} as any))
}
diff --git a/servers/nextjs/app/(presentation-generator)/services/api/theme.ts b/servers/nextjs/app/(presentation-generator)/services/api/theme.ts
new file mode 100644
index 00000000..ba4f644a
--- /dev/null
+++ b/servers/nextjs/app/(presentation-generator)/services/api/theme.ts
@@ -0,0 +1,120 @@
+import { ApiResponseHandler } from "./api-error-handler"
+import { getHeader, getHeaderForFormData } from "./header"
+import { Theme, ThemeParams } from "./types"
+
+
+
+class ThemeApi {
+
+ static async getThemes(): Promise {
+ try {
+ const response = await fetch(`/api/v1/ppt/themes/all`, {
+ method: "GET",
+ headers: getHeader(),
+ cache: "no-store",
+ })
+ return await ApiResponseHandler.handleResponse(response, "Failed to get themes")
+ } catch (error) {
+ console.error("Error getting themes:", error)
+ throw error
+ }
+ }
+ static async createTheme(theme: ThemeParams) {
+ try {
+
+ const response = await fetch(`/api/v1/ppt/themes/create`, {
+ method: "POST",
+ headers: getHeader(),
+ body: JSON.stringify(theme),
+ cache: "no-store",
+ })
+ return await ApiResponseHandler.handleResponse(response, "Failed to create theme")
+ }
+ catch (error) {
+ console.error("Error creating theme:", error)
+ throw error
+ }
+ }
+ static async updateTheme(theme: ThemeParams) {
+ try {
+ const response = await fetch(`/api/v1/ppt/themes/update/${theme.id}`, {
+ method: "PATCH",
+ headers: getHeader(),
+ body: JSON.stringify(theme),
+ cache: "no-store",
+ })
+ return await ApiResponseHandler.handleResponse(response, "Failed to update theme")
+ }
+ catch (error) {
+ console.error("Error updating theme:", error)
+ throw error
+ }
+ }
+ static async deleteTheme(themeId: string) {
+ try {
+ const response = await fetch(`/api/v1/ppt/themes/delete/${themeId}`, {
+ method: "DELETE",
+ headers: getHeader(),
+ cache: "no-store",
+ })
+ return await ApiResponseHandler.handleResponse(response, "Failed to delete theme")
+ }
+ catch (error) {
+ console.error("Error deleting theme:", error)
+ throw error
+ }
+ }
+ static async generateTheme({ primary, background }: { primary?: string, background?: string }) {
+ try {
+ let body = {}
+ if (primary || background) {
+ body = {
+ primary: primary ?? undefined,
+ background: background ?? undefined,
+ }
+ }
+ const response = await fetch(`/api/v1/ppt/theme/generate`, {
+ method: "POST",
+ headers: getHeader(),
+ body: JSON.stringify(body),
+ })
+ return await ApiResponseHandler.handleResponse(response, "Failed to generate theme")
+ }
+
+ catch (error) {
+ console.error("Error generating theme:", error)
+ throw error
+ }
+ }
+ static async uploadFont(font: File) {
+ try {
+ const formData = new FormData();
+ formData.append("file", font);
+ const response = await fetch(`/api/v1/ppt/fonts/upload`, {
+ method: "POST",
+ headers: getHeaderForFormData(),
+ body: formData,
+ })
+ return await ApiResponseHandler.handleResponse(response, "Failed to upload font")
+ }
+ catch (error) {
+ console.error("Error uploading font:", error)
+ throw error
+ }
+ }
+ static async getUserFonts() {
+ try {
+ const response = await fetch(`/api/v1/ppt/fonts/uploaded`, {
+ method: "GET",
+ headers: getHeader(),
+ })
+ return await ApiResponseHandler.handleResponse(response, "Failed to get user fonts")
+ }
+ catch (error) {
+ console.error("Error getting user fonts:", error)
+ throw error
+ }
+ }
+}
+
+export default ThemeApi
\ No newline at end of file
diff --git a/servers/nextjs/app/(presentation-generator)/services/api/types.ts b/servers/nextjs/app/(presentation-generator)/services/api/types.ts
index 1dd8201f..13d9d7b4 100644
--- a/servers/nextjs/app/(presentation-generator)/services/api/types.ts
+++ b/servers/nextjs/app/(presentation-generator)/services/api/types.ts
@@ -25,7 +25,45 @@ export interface DeplotResponse {
}
export interface ImageAssetResponse {
- message:string;
- path:string;
- id:string;
+ message: string;
+ path: string;
+ id: string;
+}
+
+
+
+export interface DefaultTheme {
+ id: string;
+ name: string;
+ description: string;
+ data: any;
+}
+
+
+export interface Theme {
+ id: string;
+ name: string;
+ description: string;
+ user: string;
+ logo: string; // image id
+ logo_url?: string; // preview url
+ company_name?: string;
+ data: any;
+}
+export interface ThemeParams {
+ id?: string;
+ name: string;
+ description: string;
+ logo: string | null; // image id
+ logo_url?: string | null; // preview url
+ data: any;
+ company_name?: string | null;
+}
+
+
+export interface DefaultTheme {
+ id: string;
+ name: string;
+ description: string;
+ data: any;
}
\ No newline at end of file
diff --git a/servers/nextjs/components/LLMSelection.tsx b/servers/nextjs/components/LLMSelection.tsx
index 82027490..32ba272f 100644
--- a/servers/nextjs/components/LLMSelection.tsx
+++ b/servers/nextjs/components/LLMSelection.tsx
@@ -12,6 +12,7 @@ import {
} from "@/utils/providerUtils";
import { LLMConfig } from "@/types/llm_config";
import ImageSelectionConfig from "./ImageSelectionConfig";
+import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
@@ -246,6 +247,7 @@ export default function LLMProviderSelection({
{/* OpenAI Content */}
=0.10.0"
}
},
+ "node_modules/react-colorful": {
+ "version": "5.6.1",
+ "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz",
+ "integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
diff --git a/servers/nextjs/package.json b/servers/nextjs/package.json
index f8a7d656..28d5797d 100644
--- a/servers/nextjs/package.json
+++ b/servers/nextjs/package.json
@@ -53,6 +53,7 @@
"prismjs": "^1.30.0",
"puppeteer": "^24.13.0",
"react": "^18.3.1",
+ "react-colorful": "^5.6.1",
"react-dom": "^18.3.1",
"react-redux": "^9.1.2",
"react-simple-code-editor": "^0.14.1",
diff --git a/servers/nextjs/store/slices/presentationGeneration.ts b/servers/nextjs/store/slices/presentationGeneration.ts
index 9c10e3dd..cf11252f 100644
--- a/servers/nextjs/store/slices/presentationGeneration.ts
+++ b/servers/nextjs/store/slices/presentationGeneration.ts
@@ -1,3 +1,4 @@
+import { Theme } from "@/app/(presentation-generator)/services/api/types";
import { Slide } from "@/app/(presentation-generator)/types/slide";
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
@@ -12,6 +13,7 @@ export interface PresentationData {
n_slides: number;
title: string;
slides: any;
+ theme: Theme;
}
interface PresentationGenerationState {
@@ -377,7 +379,13 @@ const presentationGenerationSlice = createSlice({
}
}
},
+ updateTheme: (state, action: PayloadAction) => {
+ if (state.presentationData) {
+ state.presentationData['theme'] = action.payload;
+ }
+ },
},
+
});
export const {
@@ -401,6 +409,7 @@ export const {
updateImageProperties,
updateSlideIcon,
addNewSlide,
+ updateTheme,
} = presentationGenerationSlice.actions;
export default presentationGenerationSlice.reducer;