feat: add theme management endpoints, integrate color palette generation and UI implementation
This commit is contained in:
parent
e7d3a39e0d
commit
ef078d57d2
27 changed files with 2855 additions and 260 deletions
|
|
@ -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()
|
||||
178
servers/fastapi/api/v1/ppt/endpoints/theme.py
Normal file
178
servers/fastapi/api/v1/ppt/endpoints/theme.py
Normal 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()
|
||||
75
servers/fastapi/api/v1/ppt/endpoints/theme_generate.py
Normal file
75
servers/fastapi/api/v1/ppt/endpoints/theme_generate.py
Normal 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
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
41
servers/fastapi/models/theme_data.py
Normal file
41
servers/fastapi/models/theme_data.py
Normal 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
|
||||
|
||||
357
servers/fastapi/utils/theme_utils.py
Normal file
357
servers/fastapi/utils/theme_utils.py
Normal 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,
|
||||
)
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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>)
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import React from 'react'
|
||||
import ThemePanel from './components/ThemePanel'
|
||||
const page = () => {
|
||||
return (
|
||||
<ThemePanel />
|
||||
)
|
||||
}
|
||||
|
||||
export default page
|
||||
|
|
@ -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();
|
||||
};
|
||||
|
|
@ -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]">
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
11
servers/nextjs/package-lock.json
generated
11
servers/nextjs/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue