feat: add theme management endpoints, integrate color palette generation and UI implementation

This commit is contained in:
shiva raj badu 2026-03-02 23:15:15 +05:45
parent e7d3a39e0d
commit ef078d57d2
No known key found for this signature in database
27 changed files with 2855 additions and 260 deletions

View file

@ -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)}"
)
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()

View file

@ -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()

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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,
)

View file

@ -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 = () => {
<span className="text-[11px] text-slate-800">Templates</span>
</div>
</Link>
<Link
prefetch={false}
href={`/theme`}
className={[
"flex flex-col tex-center items-center gap-2 transition-colors",
pathname === "/theme" ? "" : "ring-transparent",
].join(" ")}
aria-label="Theme"
title="Theme"
>
<div className="flex flex-col cursor-pointer tex-center items-center gap-2 transition-colors">
<Palette className={`h-4 w-4 ${pathname === "/theme" ? "text-[#5146E5]" : "text-slate-600"}`} />
<span className="text-[11px] text-slate-800">Themes</span>
</div>
</Link>
</div>
</nav>
</div>

View file

@ -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<ColorPickerComponentProps> = ({
colorKey,
currentColor,
onColorChange,
showColorPicker,
onShowColorPicker,
label
}) => (
<div className="">
{label && <p className="text-xs text-[#38393D] font-medium pb-1.5">
{label}
</p>}
<div className="flex gap-2 border border-[#EDEEEF] rounded-md p-1">
<div
className="w-8 h-8 rounded border border-gray-300 cursor-pointer relative"
style={{ backgroundColor: currentColor }}
onClick={(e) => {
e.stopPropagation()
onShowColorPicker(showColorPicker === colorKey ? null : colorKey)
}}
>
{showColorPicker === colorKey && (
<div
className="absolute top-full left-0 z-[9999] mt-2 bg-white border border-gray-300 rounded-lg shadow-lg p-2"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<HexColorPicker
color={currentColor}
onChange={(color) => onColorChange(colorKey, color)}
/>
<div className="mt-2">
<HexColorInput
color={currentColor}
onChange={(color) => onColorChange(colorKey, color)}
className="w-full px-2 py-1 text-sm border border-gray-300 rounded outline-none "
prefixed
/>
</div>
</div>
)}
</div>
<input
className='w-full outline-none text-sm font-medium text-[#38393D]'
value={currentColor}
onChange={(e) => onColorChange(colorKey, e.target.value)}
placeholder="#000000"
/>
</div>
</div>
)

View file

@ -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 (
<div
onClick={() => {
router.push('/theme?tab=new-theme')
}}
className='w-[305px] rounded-xl border border-[#EDEEEF] cursor-pointer'>
<div className='relative h-[250px] flex justify-center items-center '>
<img src="/card_bg.svg" alt="" className="absolute top-0 z-[1] left-0 w-full h-full object-cover" />
<div className='w-[36px] h-[36px] relative z-[4] rounded-full bg-[#7A5AF8] flex items-center justify-center'
style={{
background: 'linear-gradient(90deg, #F00 5.21%, #FF8A00 16.48%, #FFE600 27.74%, #14FF00 39.35%, #00A3FF 49.37%, #0500FF 61.18%, #AD00FF 72.26%, #FF00C7 83.53%, #F00 94.61%), #FFF'
}}
><div className='w-[26px] h-[26px] rounded-full bg-white flex items-center justify-center'>
<Plus className='w-4 h-4 text-[#A2A0A1]' />
</div>
</div>
</div>
<div className='px-5 py-4 bg-white flex items-center gap-4 border-t border-[#EDEEEF]'>
<div className='bg-[#7A5AF8] w-[45px] h-[45px] rounded-lg p-2 flex items-center justify-center'>
<Sparkles className='w-6 h-6 text-white' />
</div>
<div>
<h4 className='text-[#191919] text-sm font-semibold '>Build Theme</h4>
<p className='flex text-[#808080] text-sm font-medium items-center gap-2'>From colors <ArrowRight className='w-3 h-3' /> fonts </p>
</div>
</div>
</div>
)
}
export default CustomTabEmpty

View file

@ -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<FontCardProps> = ({ font, isSelected, onSelect }) => (
<div
className={`relative p-3 rounded-xl cursor-pointer transition-all duration-200 group
${isSelected
? 'bg-gradient-to-br from-[#F8F7FF] to-[#F0EFFF] border border-[#7A5AF8] shadow-sm'
: 'bg-white border border-[#EDEEEF] hover:border-[#C4B5FD] hover:bg-[#FAFAFF]'
}`}
onClick={() => onSelect(font.name)}
>
<div className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0">
<p
className={`text-sm font-medium truncate ${isSelected ? 'text-[#7A5AF8]' : 'text-[#151515]'}`}
style={{ fontFamily: `"${font.name}"` }}
>
{font.displayName}
</p>
<p
className="text-[11px] text-[#A6A4A2] mt-0.5"
style={{ fontFamily: `"${font.name}"` }}
>
ABC abc 123
</p>
</div>
<div
className={`text-xl font-semibold ${isSelected ? 'text-[#7A5AF8]' : 'text-[#333] group-hover:text-[#7A5AF8]'} transition-colors`}
style={{ fontFamily: `"${font.name}"` }}
>
Aa
</div>
</div>
</div>
)

View file

@ -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<StepIndicatorProps> = ({ currentStep }) => (
<div className="flex flex-col items-center gap-7 px-4 min-w-[104px] pt-8 border-r border-[#EDEEEF]">
{steps.map(({ step, label }) => {
const isActive = currentStep === step
return (
<div key={step} className="flex flex-col items-center gap-1.5 px-3 ">
<span
className={`px-2 py-0.5 rounded-full text-[9px] font-medium ${isActive
? 'bg-[#7A5AF8] text-white'
: 'bg-white text-[#404348] border border-[#EDEEEF]'
}`}
>
Step-{step}
</span>
<span className="text-[11px] font-normal text-black">{label}</span>
</div>
)
})}
</div>
)

View file

@ -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<ThemeCardProps> = ({ theme, onSelect, onDelete, showDeleteButton = true }) => {
if (!theme.data.colors['graph_0']) { return null }
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [copied, setCopied] = useState(false)
return (<div
className={` group rounded-xl border w-[305px] cursor-pointer transition-all relative bg-white border-[#EDEEEF] hover:shadow-sm`}
onClick={() => onSelect(theme)}
>
{showDeleteButton && <button
className="absolute hidden group-hover:block duration-300 transition-all -top-3 -right-3 z-10 bg-white rounded-full p-2 border border-[#EDEEEF] hover:bg-gray-100 hover:text-gray-700"
style={{ boxShadow: '0 6.6px 13.2px 0 rgba(0, 0, 0, 0.10)' }}
onClick={(e) => {
e.stopPropagation()
setShowDeleteDialog(true)
}}
>
<Trash className="h-3 w-3" />
</button>}
{/* Delete Confirmation Dialog */}
{showDeleteDialog && (
<div
className="fixed inset-0 z-50 flex items-center justify-center animate-[fadeIn_150ms_ease-out]"
onClick={(e) => {
e.stopPropagation()
setShowDeleteDialog(false)
}}
>
<div className="absolute inset-0 bg-black/40 backdrop-blur-[2px]" />
<div
className="relative bg-white rounded-2xl w-[340px] shadow-2xl animate-[scaleIn_200ms_ease-out] overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="p-6 pb-4 flex flex-col items-center text-center">
<div className="w-12 h-12 rounded-full bg-red-50 flex items-center justify-center mb-4">
<AlertTriangle className="h-6 w-6 text-red-500" />
</div>
<h3 className="text-lg font-semibold text-[#191919] mb-2">Delete Theme?</h3>
<p className="text-sm text-gray-500 leading-relaxed">
You're about to delete <span className="font-medium text-gray-700">"{theme.name}"</span>. This action cannot be undone.
</p>
</div>
<div className="flex border-t border-gray-100">
<button
onClick={() => setShowDeleteDialog(false)}
className="flex-1 px-4 py-3.5 text-sm font-medium text-gray-600 hover:bg-gray-50 transition-colors"
>
Cancel
</button>
<button
onClick={() => {
onDelete(theme.id)
setShowDeleteDialog(false)
}}
className="flex-1 px-4 py-3.5 text-sm font-medium text-red-500 hover:bg-red-50 transition-colors border-l border-gray-100"
>
Delete
</button>
</div>
</div>
</div>
)}
<style jsx>{`
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
`}</style>
<div className='relative h-[250px] flex justify-center items-center '>
<img src="/card_bg.svg" alt="" className="absolute top-0 z-[1] left-0 w-full h-full object-cover" />
<div className=" absolute top-0 left-0 flex items-center justify-between gap-2 z-[2] p-2">
<ToolTip content='Font' >
<p className=" text-xs font-syne flex gap-1 capitalize items-center rounded-[100px] px-2.5 py-1 bg-[#3A3A3AF5] text-white font-semibold z-40 ">
{theme.data.fonts.textFont.name}
</p>
</ToolTip>
{theme.company_name && <ToolTip content='COMPANY'>
<p className=" text-xs font-syne flex gap-1 capitalize items-center rounded-[100px] px-2.5 py-1 bg-[#3A3A3AF5] text-white font-semibold text-ellipsis overflow-hidden whitespace-nowrap z-40 ">
{theme.company_name}
</p>
</ToolTip>}
{theme.logo_url && <ToolTip content='LOGO'>
<p className=" text-xs font-syne flex gap-1 capitalize items-center rounded-[100px] px-2.5 py-1 bg-[#3A3A3AF5] text-white font-semibold z-40 ">
<img src={theme.logo_url} alt={theme.name} className="w-full max-w-6 h-4 rounded-full object-cover" />
</p>
</ToolTip>}
</div>
<div className=" relative z-[3] px-6">
<div className="w-full h-[135px]">
<div
className=" w-full h-full rounded-xl p-3 border border-black/10 overflow-hidden"
style={{ backgroundColor: theme.data.colors['background'] }}
>
<div
className="h-full w-full rounded-xl p-4 border border-black/10 shadow-[0_2px_6px_rgba(0,0,0,0.10)]"
style={{ backgroundColor: theme.data.colors['card'] }}
>
<div className="h-full w-full flex flex-col justify-center">
<div
className="text-[22px] font-semibold leading-[1.05] text-left truncate"
style={{ color: theme.data.colors['background_text'], fontFamily: `"${theme.data.fonts.textFont.name}", ui-serif, Georgia, serif` }}
>
{theme.name}
</div>
<div
className="mt-1 text-base font-medium leading-[1.1] text-left truncate"
style={{ color: theme.data.colors['background_text'], fontFamily: `"${theme.data.fonts.textFont.name}", ui-serif, Georgia, serif` }}
>
Choose your preferences.
</div>
<div
className="mt-2 h-2.5 w-16 rounded-full"
style={{ backgroundColor: theme.data.colors['primary'] }}
/>
</div>
</div>
</div>
</div>
</div>
</div>
<div className='px-5 border-t border-[#EDEEEF] w-full py-2.5 h-[80px] bg-white flex items-center justify-between'>
<div>
<h4 className='text-sm font-semibold text-[#191919] pb-1'>{theme.name}</h4>
<div className='flex items-center gap-1'>
<div className='w-4 h-4 rounded-full border border-[#EDEEEF] '
style={{ backgroundColor: theme.data.colors['primary'] }}
/>
<div
className='w-4 h-4 rounded-full border border-[#EDEEEF] '
style={{ backgroundColor: theme.data.colors['background'] }}
/>
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation()
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(theme.id)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}}
className={copied ? "text-green-500" : "text-gray-500 hover:text-gray-700"}
title={copied ? "Copied!" : "Copy ID"}
>
{copied ? <Check className="h-5 w-5" /> : <Copy className="h-5 w-5" />}
</button>
</div>
</div>)
}

View file

@ -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"
}
}
}
}
]

View file

@ -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
}

View file

@ -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)
}

View file

@ -0,0 +1,54 @@
import { Skeleton } from '@/components/ui/skeleton'
const ThemeCardSkeleton = () => (
<div className="rounded-xl px-6 border border-[#EDEEEF] w-[305px] bg-white overflow-hidden">
{/* Preview area */}
<div className="relative h-[250px] p-6">
{/* Top badges */}
<div className="absolute top-2 left-2 flex items-center gap-2 z-10">
<Skeleton className="h-6 w-16 rounded-full" />
<Skeleton className="h-6 w-20 rounded-full" />
</div>
{/* Card preview */}
<div className="h-full flex items-center justify-center">
<div className="w-full h-[135px] rounded-xl overflow-hidden">
<Skeleton className="w-full h-full" />
</div>
</div>
</div>
{/* Bottom info */}
<div className="px-5 border-t border-[#EDEEEF] py-2.5 h-[80px] flex items-center justify-between">
<div>
<Skeleton className="h-4 w-24 mb-2" />
<div className="flex items-center gap-1">
<Skeleton className="w-4 h-4 rounded-full" />
<Skeleton className="w-4 h-4 rounded-full" />
</div>
</div>
<Skeleton className="h-5 w-5" />
</div>
</div>
)
const Loading = () => {
return (
<div className="space-y-6">
{/* Tabs skeleton */}
<div className="p-1 rounded-[40px] bg-[#F7F6F9] w-fit border border-[#F4F4F4] flex items-center justify-center">
<Skeleton className="h-8 w-20 rounded-[70px]" />
<div className="mx-1 w-[2px] h-[17px] bg-[#EDECEC]" />
<Skeleton className="h-8 w-20 rounded-[70px]" />
</div>
{/* Theme cards grid */}
<div className="flex flex-wrap gap-6">
{Array.from({ length: 4 }).map((_, idx) => (
<ThemeCardSkeleton key={idx} />
))}
</div>
</div>
)
}
export default Loading

View file

@ -0,0 +1,9 @@
import React from 'react'
import ThemePanel from './components/ThemePanel'
const page = () => {
return (
<ThemePanel />
)
}
export default page

View file

@ -0,0 +1,36 @@
export const useFontLoader = (fonts: Record<string, string>) => {
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();
};

View file

@ -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<Theme[]>([]);
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<PptxPresentationModel> => {
@ -188,6 +208,7 @@ const PresentationHeader = ({
return (
<>
<div className="py-7 sticky top-0 bg-white z-50 mb-[17px] pr-[25px] flex justify-between items-center">
@ -197,7 +218,7 @@ const PresentationHeader = ({
{isPresentationSaving && <div className="flex items-center gap-2">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
</div>}
{/* <ThemeSelector presentation_id={presentation_id} current_theme={{}} themes={[]} /> */}
<ThemeSelector presentation_id={presentation_id} current_theme={presentationData?.theme || {}} themes={themes} />
<div className="flex items-center gap-2 bg-[#F6F6F9] px-3.5 h-[38px] border border-[#EDECEC] rounded-[80px]">

View file

@ -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<any>(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))
}

View file

@ -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<Theme[]> {
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

View file

@ -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;
}

View file

@ -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 */}
<TabsContent value="openai" className="mt-6">
<OpenAIConfig
llmConfig={llmConfig}
openaiApiKey={llmConfig.OPENAI_API_KEY || ""}
openaiModel={llmConfig.OPENAI_MODEL || ""}
webGrounding={llmConfig.WEB_GROUNDING || false}

View file

@ -51,6 +51,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",
@ -8117,6 +8118,16 @@
"node": ">=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",

View file

@ -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",

View file

@ -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<Theme>) => {
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;