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 - - - - + +
+ + Themes +
+ 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} +

+
} + + + +
+
+ +
+
+
+
+
+ {theme.name} +
+
+ Choose your preferences. +
+
+
+
+
+ +
+
+
+ + +
+
+ +

{theme.name}

+
+ +
+
+
+
+ + +
+
) + +} + + + 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 &&
+

Text Colors

+
+ + +
+
} + {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 ? ( +
+ +

Uploading logo...

+
+ ) : customBrandLogo ? ( +
+ Brand Logo + +
+ ) : ( + <> +
+
+ +
+
+
+ + { + 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;