feat: implement theme management and color generation endpoints

This commit is contained in:
sudipnext 2026-03-22 19:43:54 +05:45
parent fc1bad2d7c
commit 7a56045db3
8 changed files with 751 additions and 7 deletions

View file

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

View file

@ -0,0 +1,178 @@
import uuid
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select
from models.sql.image_asset import ImageAsset
from models.sql.key_value import KeyValueSqlModel
from services.database import get_async_session
THEMES_ROUTER = APIRouter(prefix="/themes", tags=["Themes"])
THEMES_STORAGE_KEY = "presentation_custom_themes"
class ThemeRequest(BaseModel):
name: str
description: str
company_name: Optional[str] = None
logo: Optional[str] = None
logo_url: Optional[str] = None
data: dict[str, Any] = Field(default_factory=dict)
class ThemeUpdateRequest(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
company_name: Optional[str] = None
logo: Optional[str] = None
logo_url: Optional[str] = None
data: Optional[dict[str, Any]] = None
class ThemeResponse(BaseModel):
id: str
name: str
description: str
user: str
logo: Optional[str] = None
logo_url: Optional[str] = None
company_name: Optional[str] = None
data: dict[str, Any]
def _normalize_theme(theme: dict[str, Any]) -> ThemeResponse:
return ThemeResponse(
id=str(theme["id"]),
name=theme["name"],
description=theme["description"],
user=theme.get("user", "local"),
logo=theme.get("logo"),
logo_url=theme.get("logo_url"),
company_name=theme.get("company_name"),
data=theme.get("data", {}),
)
async def _get_themes_row(sql_session: AsyncSession) -> Optional[KeyValueSqlModel]:
return await sql_session.scalar(
select(KeyValueSqlModel).where(KeyValueSqlModel.key == THEMES_STORAGE_KEY)
)
def _read_themes_from_row(row: Optional[KeyValueSqlModel]) -> list[dict[str, Any]]:
if not row:
return []
value = row.value if isinstance(row.value, dict) else {}
themes = value.get("themes", [])
return themes if isinstance(themes, list) else []
async def _resolve_logo_url(
sql_session: AsyncSession, logo: Optional[str]
) -> Optional[str]:
if not logo:
return None
try:
logo_uuid = uuid.UUID(str(logo))
except ValueError as exc:
raise HTTPException(status_code=400, detail="Invalid logo id") from exc
image_asset = await sql_session.get(ImageAsset, logo_uuid)
if not image_asset:
raise HTTPException(status_code=404, detail="Logo not found")
return image_asset.path
@THEMES_ROUTER.get("/default", response_model=List[dict[str, Any]])
async def get_default_themes():
# Built-in themes are provided by Next.js constants in this project.
return []
@THEMES_ROUTER.get("/all", response_model=List[ThemeResponse])
async def get_themes(sql_session: AsyncSession = Depends(get_async_session)):
row = await _get_themes_row(sql_session)
themes = _read_themes_from_row(row)
return [_normalize_theme(theme) for theme in themes]
@THEMES_ROUTER.post("/create", response_model=ThemeResponse)
async def create_theme(
payload: ThemeRequest, sql_session: AsyncSession = Depends(get_async_session)
):
row = await _get_themes_row(sql_session)
themes = _read_themes_from_row(row)
logo_url = payload.logo_url or await _resolve_logo_url(sql_session, payload.logo)
theme = {
"id": str(uuid.uuid4()),
"name": payload.name,
"description": payload.description,
"user": "local",
"logo": payload.logo,
"logo_url": logo_url,
"company_name": payload.company_name,
"data": payload.data,
}
themes.append(theme)
if row:
row.value = {"themes": themes}
sql_session.add(row)
else:
sql_session.add(KeyValueSqlModel(key=THEMES_STORAGE_KEY, value={"themes": themes}))
await sql_session.commit()
return _normalize_theme(theme)
@THEMES_ROUTER.patch("/update/{theme_id}", response_model=ThemeResponse)
async def update_theme(
theme_id: str,
payload: ThemeUpdateRequest,
sql_session: AsyncSession = Depends(get_async_session),
):
row = await _get_themes_row(sql_session)
if not row:
raise HTTPException(status_code=404, detail="Theme not found")
themes = _read_themes_from_row(row)
theme = next((item for item in themes if item.get("id") == theme_id), None)
if not theme:
raise HTTPException(status_code=404, detail="Theme not found")
if payload.name is not None:
theme["name"] = payload.name
if payload.description is not None:
theme["description"] = payload.description
if payload.company_name is not None:
theme["company_name"] = payload.company_name
if payload.data is not None:
theme["data"] = payload.data
if payload.logo is not None:
theme["logo"] = payload.logo
theme["logo_url"] = await _resolve_logo_url(sql_session, payload.logo)
elif payload.logo_url is not None:
theme["logo_url"] = payload.logo_url
row.value = {"themes": themes}
sql_session.add(row)
await sql_session.commit()
return _normalize_theme(theme)
@THEMES_ROUTER.delete("/delete/{theme_id}", status_code=204)
async def delete_theme(
theme_id: str, sql_session: AsyncSession = Depends(get_async_session)
):
row = await _get_themes_row(sql_session)
if not row:
return
themes = _read_themes_from_row(row)
row.value = {"themes": [theme for theme in themes if theme.get("id") != theme_id]}
sql_session.add(row)
await sql_session.commit()

View file

@ -0,0 +1,75 @@
from typing import Optional
from fastapi import APIRouter
from pydantic import BaseModel
from models.theme_data import ThemeData
from utils.theme_utils import (
IS_DARK_BELOW,
generate_color_palette,
get_lightness_key_at_distance,
)
THEME_ROUTER = APIRouter(prefix="/theme", tags=["V3 Theme"])
class GenerateThemeRequestV3(BaseModel):
primary: Optional[str] = None
background: Optional[str] = None
accent_1: Optional[str] = None
accent_2: Optional[str] = None
text_1: Optional[str] = None
text_2: Optional[str] = None
@THEME_ROUTER.post("/generate", response_model=ThemeData)
async def generate_theme_v3(request: GenerateThemeRequestV3) -> ThemeData:
color_palette = generate_color_palette(
request.primary,
request.background,
request.accent_1,
request.accent_2,
request.text_1,
request.text_2,
)
is_dark_theme = color_palette.background_lightness < IS_DARK_BELOW
graph_colors = list(color_palette.primary_variations.values())
if not is_dark_theme:
graph_colors.reverse()
theme_data = ThemeData(
primary=color_palette.primary,
background=color_palette.background,
card=color_palette.background_variations[
get_lightness_key_at_distance(
color_palette.background_lightness,
min_distance=1,
max_distance=1,
prefer_dark=not is_dark_theme,
)
],
stroke=color_palette.background_variations[
get_lightness_key_at_distance(
color_palette.background_lightness,
min_distance=2,
max_distance=2,
prefer_dark=not is_dark_theme,
)
],
background_text=color_palette.text_1,
primary_text=color_palette.text_2,
graph_0=graph_colors[0],
graph_1=graph_colors[1],
graph_2=graph_colors[2],
graph_3=graph_colors[3],
graph_4=graph_colors[4],
graph_5=graph_colors[5],
graph_6=graph_colors[6],
graph_7=graph_colors[7],
graph_8=graph_colors[8],
graph_9=graph_colors[9],
)
return theme_data

View file

@ -16,6 +16,8 @@ from api.v1.ppt.endpoints.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)

View file

@ -0,0 +1,41 @@
from pydantic import BaseModel
from typing import Dict
class ThemeData(BaseModel):
primary: str
background: str
card: str
stroke: str
background_text: str
primary_text: str
graph_0: str
graph_1: str
graph_2: str
graph_3: str
graph_4: str
graph_5: str
graph_6: str
graph_7: str
graph_8: str
graph_9: str
class GeneratedColorPalette(BaseModel):
primary: str
background: str
accent_1: str
accent_2: str
text_1: str
text_2: str
primary_variations: Dict[str, str]
background_variations: Dict[str, str]
accent_1_variations: Dict[str, str]
accent_2_variations: Dict[str, str]
primary_lightness: float
background_lightness: float
accent_1_lightness: float
accent_2_lightness: float
text_1_lightness: float
text_2_lightness: float

View file

@ -0,0 +1,357 @@
from __future__ import annotations
import math
import random
from dataclasses import dataclass
from typing import Dict, Optional
from models.theme_data import GeneratedColorPalette
IS_DARK_BELOW = 0.65
BACKGROUND_RETRIES = 200
TEXT_RETRIES = 200
LIGHTNESS_VALUES: Dict[str, float] = {
"50": 0.97,
"100": 0.93,
"200": 0.86,
"300": 0.78,
"400": 0.70,
"500": 0.62,
"600": 0.54,
"700": 0.46,
"800": 0.38,
"900": 0.30,
}
@dataclass(frozen=True)
class Oklch:
l: float # noqa: E741
c: float
h: float
def _clamp(value: float, min_value: float = 0.0, max_value: float = 1.0) -> float:
return max(min_value, min(max_value, value))
def _get_random_value(min_value: float, max_value: float) -> float:
return min_value + random.random() * (max_value - min_value)
def _get_random_value_at_min_max_distance(
base_value: float,
min_value: float,
max_value: float,
min_distance: Optional[float] = None,
max_distance: Optional[float] = None,
) -> float:
normalized_min_distance = max(0.0, min_distance or 0.0)
normalized_max_distance = max_distance if max_distance is not None else math.inf
min_dist = min(normalized_min_distance, normalized_max_distance)
max_dist = max(normalized_min_distance, normalized_max_distance)
lower_start = max(min_value, base_value - max_dist)
lower_end = min(max_value, base_value - min_dist)
upper_start = max(min_value, base_value + min_dist)
upper_end = min(max_value, base_value + max_dist)
lower_size = max(0.0, lower_end - lower_start)
upper_size = max(0.0, upper_end - upper_start)
total_size = lower_size + upper_size
if total_size <= 0:
return _get_random_value(min_value, max_value)
picker = random.random() * total_size
if picker < lower_size:
return _get_random_value(lower_start, lower_end)
return _get_random_value(upper_start, upper_end)
def _srgb_to_linear(channel: float) -> float:
if channel <= 0.04045:
return channel / 12.92
return ((channel + 0.055) / 1.055) ** 2.4
def _linear_to_srgb(channel: float) -> float:
if channel <= 0.0031308:
return 12.92 * channel
return 1.055 * (channel ** (1 / 2.4)) - 0.055
def _oklch_to_srgb(color: Oklch) -> tuple[float, float, float]:
hue_rad = math.radians(color.h % 360)
a = color.c * math.cos(hue_rad)
b = color.c * math.sin(hue_rad)
l_ = (color.l + 0.3963377774 * a + 0.2158037573 * b) ** 3
m_ = (color.l - 0.1055613458 * a - 0.0638541728 * b) ** 3
s_ = (color.l - 0.0894841775 * a - 1.2914855480 * b) ** 3
r = 4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_
g = -1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_
b = -0.0041960863 * l_ - 0.7034186147 * m_ + 1.7076147010 * s_
return (
_clamp(_linear_to_srgb(r)),
_clamp(_linear_to_srgb(g)),
_clamp(_linear_to_srgb(b)),
)
def _srgb_to_oklch(r: float, g: float, b: float) -> Oklch:
r_lin = _srgb_to_linear(r)
g_lin = _srgb_to_linear(g)
b_lin = _srgb_to_linear(b)
l_ = 0.4122214708 * r_lin + 0.5363325363 * g_lin + 0.0514459929 * b_lin
m_ = 0.2119034982 * r_lin + 0.6806995451 * g_lin + 0.1073969566 * b_lin
s_ = 0.0883024619 * r_lin + 0.2817188376 * g_lin + 0.6299787005 * b_lin
l_cbrt = math.copysign(abs(l_) ** (1 / 3), l_)
m_cbrt = math.copysign(abs(m_) ** (1 / 3), m_)
s_cbrt = math.copysign(abs(s_) ** (1 / 3), s_)
lightness = 0.2104542553 * l_cbrt + 0.7936177850 * m_cbrt - 0.0040720468 * s_cbrt
a = 1.9779984951 * l_cbrt - 2.4285922050 * m_cbrt + 0.4505937099 * s_cbrt
b = 0.0259040371 * l_cbrt + 0.7827717662 * m_cbrt - 0.8086757660 * s_cbrt
chroma = math.hypot(a, b)
hue = math.degrees(math.atan2(b, a)) % 360
return Oklch(l=lightness, c=chroma, h=hue)
def _hex_to_oklch(hex_value: str) -> Oklch:
hex_value = hex_value.strip().lstrip("#")
if len(hex_value) != 6:
raise ValueError(f"Invalid hex color: {hex_value!r}")
r = int(hex_value[0:2], 16) / 255.0
g = int(hex_value[2:4], 16) / 255.0
b = int(hex_value[4:6], 16) / 255.0
return _srgb_to_oklch(r, g, b)
def _format_hex(color: Oklch) -> str:
r, g, b = _oklch_to_srgb(color)
return "#{:02x}{:02x}{:02x}".format(
int(round(r * 255)),
int(round(g * 255)),
int(round(b * 255)),
)
def _relative_luminance(color: Oklch) -> float:
r, g, b = _oklch_to_srgb(color)
r_lin = _srgb_to_linear(r)
g_lin = _srgb_to_linear(g)
b_lin = _srgb_to_linear(b)
return 0.2126 * r_lin + 0.7152 * g_lin + 0.0722 * b_lin
def _wcag_contrast(a: Oklch, b: Oklch) -> float:
l1 = _relative_luminance(a)
l2 = _relative_luminance(b)
lighter = max(l1, l2)
darker = min(l1, l2)
return (lighter + 0.05) / (darker + 0.05)
def _get_color_for_all_lightness_values(base_color: Oklch) -> Dict[str, str]:
colors: Dict[str, str] = {}
for name, value in LIGHTNESS_VALUES.items():
color = Oklch(l=value, c=base_color.c, h=base_color.h)
colors[name] = _format_hex(color)
return colors
def _generate_primary_color() -> Oklch:
lightness = _get_random_value(0.0, 1.0)
chroma = _get_random_value(0.0, 0.4)
hue = _get_random_value(0.0, 360.0)
return Oklch(l=lightness, c=chroma, h=hue)
def _generate_background_color(base_color: Oklch) -> Oklch:
for _ in range(BACKGROUND_RETRIES):
lightness = _get_random_value(0.0, 1.0)
chroma = _get_random_value(0.0, 0.4)
hue = _get_random_value(0.0, 360.0)
color = Oklch(l=lightness, c=chroma, h=hue)
if _wcag_contrast(color, base_color) >= 6:
return color
if base_color.l < IS_DARK_BELOW:
return Oklch(l=1.0, c=0.0, h=0.0)
return Oklch(l=0.0, c=0.0, h=0.0)
def _generate_accent_color(base_color: Oklch, n: int) -> Oklch:
lightness = _get_random_value_at_min_max_distance(base_color.l, 0.0, 1.0, 0.0, 0.1)
chroma = _get_random_value_at_min_max_distance(base_color.c, 0.0, 0.4, 0.0, 0.4)
hue = _get_random_value_at_min_max_distance(
base_color.h if base_color.h is not None else 0.0,
0.0,
360.0,
n * 90.0,
(n + 1) * 90.0,
)
return Oklch(l=lightness, c=chroma, h=hue)
def _generate_text_color(base_color: Oklch, text_type: str) -> Oklch:
is_base_dark = base_color.l < IS_DARK_BELOW
for _ in range(TEXT_RETRIES):
if text_type == "text_1":
lightness = (
_get_random_value(0.8, 1.0)
if is_base_dark
else _get_random_value(0.0, 0.2)
)
chroma = _get_random_value(0.0, 0.02)
elif text_type == "text_2":
lightness = (
_get_random_value(0.8, 1.0)
if is_base_dark
else _get_random_value(0.0, 0.2)
)
chroma = _get_random_value(0.0, 0.04)
else:
raise ValueError(f"Invalid text type: {text_type}")
hue = _get_random_value(0.0, 360.0)
color = Oklch(l=lightness, c=chroma, h=hue)
min_contrast = 6.0
max_contrast = None
contrast = _wcag_contrast(color, base_color)
if contrast >= min_contrast and (
max_contrast is None or contrast <= max_contrast
):
return color
if base_color.l < IS_DARK_BELOW:
return Oklch(l=1.0 if text_type == "text_1" else 0.9, c=0.0, h=0.0)
return Oklch(l=0.0 if text_type == "text_1" else 0.1, c=0.0, h=0.0)
def get_lightness_key_at_distance(
value: float,
min_distance: Optional[int] = None,
max_distance: Optional[int] = None,
prefer_dark: Optional[bool] = None,
) -> str:
items = sorted(LIGHTNESS_VALUES.items(), key=lambda item: item[1])
nearest_index = 0
nearest_distance = abs(items[0][1] - value)
for index, (_, lightness) in enumerate(items[1:], start=1):
distance = abs(lightness - value)
if distance < nearest_distance or (
distance == nearest_distance and lightness < items[nearest_index][1]
):
nearest_index = index
nearest_distance = distance
normalized_min = max(0, min_distance or 0)
normalized_max = max_distance if max_distance is not None else normalized_min
if normalized_max < normalized_min:
normalized_min, normalized_max = normalized_max, normalized_min
candidate_indices = []
for distance in range(normalized_min, normalized_max + 1):
lower_index = nearest_index - distance
upper_index = nearest_index + distance
if 0 <= lower_index < len(items):
candidate_indices.append(lower_index)
if upper_index != lower_index and 0 <= upper_index < len(items):
candidate_indices.append(upper_index)
if not candidate_indices:
return items[nearest_index][0]
if prefer_dark is True:
darker_candidates = [idx for idx in candidate_indices if idx <= nearest_index]
if darker_candidates:
return items[min(darker_candidates)][0]
return items[min(candidate_indices)][0]
if prefer_dark is False:
lighter_candidates = [idx for idx in candidate_indices if idx >= nearest_index]
if lighter_candidates:
return items[max(lighter_candidates)][0]
return items[max(candidate_indices)][0]
def distance_to_value(idx: int) -> float:
return abs(items[idx][1] - value)
closest_index = min(candidate_indices, key=lambda idx: (distance_to_value(idx), idx))
return items[closest_index][0]
def generate_color_palette(
provided_primary: Optional[str] = None,
provided_background: Optional[str] = None,
provided_accent_1: Optional[str] = None,
provided_accent_2: Optional[str] = None,
provided_text_1: Optional[str] = None,
provided_text_2: Optional[str] = None,
) -> GeneratedColorPalette:
primary = (
_hex_to_oklch(provided_primary) if provided_primary else _generate_primary_color()
)
background = (
_hex_to_oklch(provided_background)
if provided_background
else _generate_background_color(primary)
)
accent_1 = (
_hex_to_oklch(provided_accent_1)
if provided_accent_1
else _generate_accent_color(primary, 1)
)
accent_2 = (
_hex_to_oklch(provided_accent_2)
if provided_accent_2
else _generate_accent_color(primary, 2)
)
text_1 = (
_hex_to_oklch(provided_text_1)
if provided_text_1
else _generate_text_color(background, "text_1")
)
text_2 = (
_hex_to_oklch(provided_text_2)
if provided_text_2
else _generate_text_color(primary, "text_2")
)
primary_variations = _get_color_for_all_lightness_values(primary)
background_variations = _get_color_for_all_lightness_values(background)
accent_1_variations = _get_color_for_all_lightness_values(accent_1)
accent_2_variations = _get_color_for_all_lightness_values(accent_2)
return GeneratedColorPalette(
primary=_format_hex(primary),
background=_format_hex(background),
accent_1=_format_hex(accent_1),
accent_2=_format_hex(accent_2),
text_1=_format_hex(text_1),
text_2=_format_hex(text_2),
primary_variations=primary_variations,
background_variations=background_variations,
accent_1_variations=accent_1_variations,
accent_2_variations=accent_2_variations,
primary_lightness=primary.l,
background_lightness=background.l,
accent_1_lightness=accent_1.l,
accent_2_lightness=accent_2.l,
text_1_lightness=text_1.l,
text_2_lightness=text_2.l,
)

View file

@ -1,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<any> {
// 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) {

View file

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