feat: implement theme management and color generation endpoints
This commit is contained in:
parent
fc1bad2d7c
commit
7a56045db3
8 changed files with 751 additions and 7 deletions
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
178
electron/servers/fastapi/api/v1/ppt/endpoints/theme.py
Normal file
178
electron/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()
|
||||
|
|
@ -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.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)
|
||||
|
|
|
|||
41
electron/servers/fastapi/models/theme_data.py
Normal file
41
electron/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
electron/servers/fastapi/utils/theme_utils.py
Normal file
357
electron/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,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) {
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue