diff --git a/electron/servers/fastapi/api/v1/ppt/endpoints/fonts.py b/electron/servers/fastapi/api/v1/ppt/endpoints/fonts.py index ecf7ca83..a2a821cd 100644 --- a/electron/servers/fastapi/api/v1/ppt/endpoints/fonts.py +++ b/electron/servers/fastapi/api/v1/ppt/endpoints/fonts.py @@ -39,6 +39,10 @@ class FontListResponse(BaseModel): message: Optional[str] = None +class UploadedFontsResponse(BaseModel): + fonts: List[dict] + + def get_fonts_directory() -> str: """Get the fonts directory path, create if it doesn't exist""" app_data_dir = get_app_data_directory_env() or "/tmp/presenton" @@ -244,6 +248,45 @@ async def list_fonts(): ) +@FONTS_ROUTER.get("/uploaded", response_model=UploadedFontsResponse) +async def get_uploaded_fonts(): + """ + Compatibility endpoint used by frontend theme flow. + Returns uploaded fonts as a compact list with id/name/url fields. + """ + try: + fonts_dir = get_fonts_directory() + fonts = [] + + if os.path.exists(fonts_dir): + for filename in os.listdir(fonts_dir): + file_path = os.path.join(fonts_dir, filename) + if not os.path.isfile(file_path): + continue + + file_ext = os.path.splitext(filename)[1].lower() + if file_ext not in SUPPORTED_FONT_EXTENSIONS: + continue + + font_name = extract_font_name_from_file(file_path) + fonts.append( + { + "id": filename, + "name": font_name, + "url": f"/app_data/fonts/{filename}", + } + ) + + return UploadedFontsResponse(fonts=fonts) + + except Exception as e: + print(f"Error getting uploaded fonts: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"Error getting uploaded fonts: {str(e)}" + ) + + @FONTS_ROUTER.delete("/delete/{filename}") async def delete_font(filename: str): """ diff --git a/electron/servers/fastapi/api/v1/ppt/endpoints/theme.py b/electron/servers/fastapi/api/v1/ppt/endpoints/theme.py new file mode 100644 index 00000000..4ddd38d9 --- /dev/null +++ b/electron/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/electron/servers/fastapi/api/v1/ppt/endpoints/theme_generate.py b/electron/servers/fastapi/api/v1/ppt/endpoints/theme_generate.py new file mode 100644 index 00000000..94590feb --- /dev/null +++ b/electron/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/electron/servers/fastapi/api/v1/ppt/router.py b/electron/servers/fastapi/api/v1/ppt/router.py index 6918add4..9439cea1 100644 --- a/electron/servers/fastapi/api/v1/ppt/router.py +++ b/electron/servers/fastapi/api/v1/ppt/router.py @@ -16,6 +16,8 @@ from api.v1.ppt.endpoints.outlines import OUTLINES_ROUTER from api.v1.ppt.endpoints.slide import SLIDE_ROUTER from api.v1.ppt.endpoints.codex_auth import CODEX_AUTH_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") @@ -32,6 +34,8 @@ API_V1_PPT_ROUTER.include_router(HTML_EDIT_ROUTER) API_V1_PPT_ROUTER.include_router(LAYOUT_MANAGEMENT_ROUTER) API_V1_PPT_ROUTER.include_router(IMAGES_ROUTER) API_V1_PPT_ROUTER.include_router(ICONS_ROUTER) +API_V1_PPT_ROUTER.include_router(THEMES_ROUTER) +API_V1_PPT_ROUTER.include_router(THEME_ROUTER) API_V1_PPT_ROUTER.include_router(OLLAMA_ROUTER) API_V1_PPT_ROUTER.include_router(PDF_SLIDES_ROUTER) API_V1_PPT_ROUTER.include_router(OPENAI_ROUTER) diff --git a/electron/servers/fastapi/models/theme_data.py b/electron/servers/fastapi/models/theme_data.py new file mode 100644 index 00000000..1383b338 --- /dev/null +++ b/electron/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/electron/servers/fastapi/utils/theme_utils.py b/electron/servers/fastapi/utils/theme_utils.py new file mode 100644 index 00000000..3838d7f7 --- /dev/null +++ b/electron/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/electron/servers/nextjs/app/(presentation-generator)/services/api/api-error-handler.ts b/electron/servers/nextjs/app/(presentation-generator)/services/api/api-error-handler.ts index eae66bf0..b7949572 100644 --- a/electron/servers/nextjs/app/(presentation-generator)/services/api/api-error-handler.ts +++ b/electron/servers/nextjs/app/(presentation-generator)/services/api/api-error-handler.ts @@ -1,12 +1,49 @@ // API Error Response Interface interface ApiErrorResponse { - detail?: string; + detail?: unknown; message?: string; error?: string; } // API Response Handler Utility export class ApiResponseHandler { + private static normalizeErrorDetail(detail: unknown): string | null { + if (!detail) return null; + + if (typeof detail === "string") { + return detail; + } + + if (Array.isArray(detail)) { + const parts = detail + .map((item) => { + if (typeof item === "string") return item; + if (item && typeof item === "object") { + const maybeMsg = (item as { msg?: unknown }).msg; + const maybeLoc = (item as { loc?: unknown }).loc; + const locPath = Array.isArray(maybeLoc) + ? maybeLoc + .filter((v) => typeof v === "string" || typeof v === "number") + .join(".") + : ""; + if (typeof maybeMsg === "string") { + return locPath ? `${locPath}: ${maybeMsg}` : maybeMsg; + } + } + return null; + }) + .filter((v): v is string => Boolean(v)); + + return parts.length ? parts.join("; ") : JSON.stringify(detail); + } + + if (typeof detail === "object") { + return JSON.stringify(detail); + } + + return String(detail); + } + static async handleResponse(response: Response, defaultErrorMessage: string): Promise { // Handle successful responses @@ -32,8 +69,9 @@ export class ApiResponseHandler { const errorData: ApiErrorResponse = await response.json(); // Extract error message in order of preference - if (errorData.detail) { - errorMessage = errorData.detail; + const normalizedDetail = this.normalizeErrorDetail(errorData.detail); + if (normalizedDetail) { + errorMessage = normalizedDetail; } else if (errorData.message) { errorMessage = errorData.message; } else if (errorData.error) { @@ -63,8 +101,9 @@ export class ApiResponseHandler { const errorData: ApiErrorResponse = await response.json(); // Extract error message in order of preference - if (errorData.detail) { - errorMessage = errorData.detail; + const normalizedDetail = this.normalizeErrorDetail(errorData.detail); + if (normalizedDetail) { + errorMessage = normalizedDetail; } else if (errorData.message) { errorMessage = errorData.message; } else if (errorData.error) { diff --git a/electron/servers/nextjs/app/hooks/useRemoteSvgIcon.tsx b/electron/servers/nextjs/app/hooks/useRemoteSvgIcon.tsx index 3b6c09cb..8a4e252a 100644 --- a/electron/servers/nextjs/app/hooks/useRemoteSvgIcon.tsx +++ b/electron/servers/nextjs/app/hooks/useRemoteSvgIcon.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { getFastAPIUrl } from "@/utils/api"; export type RemoteSvgOptions = { strokeColor?: string; @@ -144,8 +145,14 @@ export function useRemoteSvgIcon(url?: string, options: RemoteSvgOptions = {}) { return; } try { - - const res = await fetch(url); + // If URL starts with /static or /app_data, proxy it through FastAPI. + let fetchUrl = url; + if (url.startsWith('/static/') || url.startsWith('/app_data/')) { + const fastApiUrl = getFastAPIUrl(); + fetchUrl = `${fastApiUrl}${url}`; + } + + const res = await fetch(fetchUrl); if (!res.ok) { throw new Error(`HTTP ${res.status}`); }