Merge branch 'feat/custom_schema_and_layout' of https://github.com/presenton/presenton into feat/custom_schema_and_layout
merge
This commit is contained in:
commit
130d6b7141
60 changed files with 4742 additions and 3758 deletions
|
|
@ -5,12 +5,18 @@ from fastapi import FastAPI
|
|||
from sqlmodel import SQLModel
|
||||
|
||||
from services import SQL_ENGINE
|
||||
from utils.model_availability import check_llm_model_availability
|
||||
from utils.get_env import get_app_data_directory_env
|
||||
from utils.model_availability import check_llm_and_image_provider_api_or_model_availability
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def app_lifespan(_: FastAPI):
|
||||
os.makedirs(os.getenv("APP_DATA_DIRECTORY"), exist_ok=True)
|
||||
"""
|
||||
Lifespan context manager for FastAPI application.
|
||||
Initializes the application data directory and checks LLM model availability.
|
||||
|
||||
"""
|
||||
os.makedirs(get_app_data_directory_env(), exist_ok=True)
|
||||
SQLModel.metadata.create_all(SQL_ENGINE)
|
||||
await check_llm_model_availability()
|
||||
await check_llm_and_image_provider_api_or_model_availability()
|
||||
yield
|
||||
|
|
|
|||
59
servers/fastapi/api/v1/ppt/background_tasks.py
Normal file
59
servers/fastapi/api/v1/ppt/background_tasks.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import json
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from models.ollama_model_status import OllamaModelStatus
|
||||
from services import REDIS_SERVICE
|
||||
from utils.ollama import pull_ollama_model
|
||||
|
||||
|
||||
async def pull_ollama_model_background_task(model: str):
|
||||
saved_model_status = OllamaModelStatus(
|
||||
name=model,
|
||||
status="pulling",
|
||||
done=False,
|
||||
)
|
||||
log_event_count = 0
|
||||
|
||||
try:
|
||||
async for event in pull_ollama_model(model):
|
||||
log_event_count += 1
|
||||
if log_event_count != 1 and log_event_count % 20 != 0:
|
||||
continue
|
||||
|
||||
if "completed" in event:
|
||||
saved_model_status.downloaded = event["completed"]
|
||||
|
||||
if not saved_model_status.size and "total" in event:
|
||||
saved_model_status.size = event["total"]
|
||||
|
||||
if "status" in event:
|
||||
saved_model_status.status = event["status"]
|
||||
|
||||
REDIS_SERVICE.set(
|
||||
f"ollama_models/{model}",
|
||||
json.dumps(saved_model_status.model_dump(mode="json")),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
saved_model_status.status = "error"
|
||||
saved_model_status.done = True
|
||||
REDIS_SERVICE.set(
|
||||
f"ollama_models/{model}",
|
||||
json.dumps(saved_model_status.model_dump(mode="json")),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to pull model: {e}",
|
||||
)
|
||||
|
||||
saved_model_status.done = True
|
||||
saved_model_status.status = "pulled"
|
||||
saved_model_status.downloaded = saved_model_status.size
|
||||
|
||||
REDIS_SERVICE.set(
|
||||
f"ollama_models/{model}",
|
||||
json.dumps(saved_model_status.model_dump(mode="json")),
|
||||
)
|
||||
|
||||
return saved_model_status
|
||||
14
servers/fastapi/api/v1/ppt/endpoints/custom_llm.py
Normal file
14
servers/fastapi/api/v1/ppt/endpoints/custom_llm.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
from typing import Annotated, List, Optional
|
||||
from fastapi import APIRouter, Body
|
||||
|
||||
from utils.custom_llm_provider import list_available_custom_models
|
||||
|
||||
CUSTOM_LLM_ROUTER = APIRouter(prefix="/custom_llm", tags=["Custom LLM"])
|
||||
|
||||
|
||||
@CUSTOM_LLM_ROUTER.post("/models/available", response_model=List[str])
|
||||
async def get_available_models(
|
||||
url: Annotated[Optional[str], Body()] = None,
|
||||
api_key: Annotated[Optional[str], Body()] = None,
|
||||
):
|
||||
return await list_available_custom_models(url, api_key)
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
from http.client import HTTPException
|
||||
import os
|
||||
from typing import Annotated, List, Optional
|
||||
import uuid
|
||||
from fastapi import APIRouter, Body, File, UploadFile
|
||||
|
||||
from constants.documents import UPLOAD_ACCEPTED_FILE_TYPES
|
||||
from models.decomposed_file_info import DecomposedFileInfo
|
||||
from services import TEMP_FILE_SERVICE
|
||||
from services.documents_loader import DocumentsLoader
|
||||
from utils.randomizers import get_random_uuid
|
||||
from utils.validators import validate_files
|
||||
|
||||
FILES_ROUTER = APIRouter(prefix="/files", tags=["Files"])
|
||||
|
|
@ -18,7 +18,7 @@ async def upload_files(files: Optional[List[UploadFile]]):
|
|||
if not files:
|
||||
raise HTTPException(400, "Documents are required")
|
||||
|
||||
temp_dir = TEMP_FILE_SERVICE.create_temp_dir(str(uuid.uuid4()))
|
||||
temp_dir = TEMP_FILE_SERVICE.create_temp_dir(get_random_uuid())
|
||||
|
||||
validate_files(files, True, True, 50, UPLOAD_ACCEPTED_FILE_TYPES)
|
||||
|
||||
|
|
@ -39,7 +39,7 @@ async def upload_files(files: Optional[List[UploadFile]]):
|
|||
|
||||
@FILES_ROUTER.post("/decompose", response_model=List[DecomposedFileInfo])
|
||||
async def decompose_files(file_paths: Annotated[List[str], Body(embed=True)]):
|
||||
temp_dir = TEMP_FILE_SERVICE.create_temp_dir(str(uuid.uuid4()))
|
||||
temp_dir = TEMP_FILE_SERVICE.create_temp_dir(get_random_uuid())
|
||||
|
||||
txt_files = []
|
||||
other_files = []
|
||||
|
|
@ -56,7 +56,7 @@ async def decompose_files(file_paths: Annotated[List[str], Body(embed=True)]):
|
|||
response = []
|
||||
for index, parsed_doc in enumerate(parsed_documents):
|
||||
file_path = TEMP_FILE_SERVICE.create_temp_file_path(
|
||||
f"{str(uuid.uuid4())}.txt", temp_dir
|
||||
f"{get_random_uuid()}.txt", temp_dir
|
||||
)
|
||||
parsed_doc = parsed_doc.replace("<br>", "\n")
|
||||
with open(file_path, "w") as text_file:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
from fastapi import APIRouter
|
||||
|
||||
from models.image_prompt import ImagePrompt
|
||||
from models.sql.image_asset import ImageAsset
|
||||
from services.database import get_sql_session
|
||||
from services.image_generation_service import ImageGenerationService
|
||||
from utils.asset_directory_utils import get_images_directory
|
||||
|
||||
|
|
@ -13,4 +15,12 @@ async def generate_image(prompt: str):
|
|||
image_prompt = ImagePrompt(prompt=prompt)
|
||||
image_generation_service = ImageGenerationService(images_directory)
|
||||
|
||||
return await image_generation_service.generate_image(image_prompt)
|
||||
image = await image_generation_service.generate_image(image_prompt)
|
||||
if not isinstance(image, ImageAsset):
|
||||
return image
|
||||
|
||||
with get_sql_session() as sql_session:
|
||||
sql_session.add(image)
|
||||
sql_session.commit()
|
||||
|
||||
return image.path
|
||||
|
|
|
|||
72
servers/fastapi/api/v1/ppt/endpoints/ollama.py
Normal file
72
servers/fastapi/api/v1/ppt/endpoints/ollama.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import json
|
||||
from typing import List
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException
|
||||
|
||||
from api.v1.ppt.background_tasks import pull_ollama_model_background_task
|
||||
from constants.supported_ollama_models import SUPPORTED_OLLAMA_MODELS
|
||||
from models.ollama_model_metadata import OllamaModelMetadata
|
||||
from models.ollama_model_status import OllamaModelStatus
|
||||
from services import REDIS_SERVICE
|
||||
from utils.ollama import list_pulled_ollama_models
|
||||
|
||||
OLLAMA_ROUTER = APIRouter(prefix="/ollama", tags=["Ollama"])
|
||||
|
||||
|
||||
@OLLAMA_ROUTER.get("/models/supported", response_model=List[OllamaModelMetadata])
|
||||
def get_supported_models():
|
||||
return SUPPORTED_OLLAMA_MODELS.values()
|
||||
|
||||
|
||||
@OLLAMA_ROUTER.get("/models/available", response_model=List[OllamaModelStatus])
|
||||
async def get_available_models():
|
||||
return await list_pulled_ollama_models()
|
||||
|
||||
|
||||
@OLLAMA_ROUTER.get("/model/pull", response_model=OllamaModelStatus)
|
||||
async def pull_model(model: str, background_tasks: BackgroundTasks):
|
||||
|
||||
if model not in SUPPORTED_OLLAMA_MODELS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Model {model} is not supported",
|
||||
)
|
||||
|
||||
try:
|
||||
pulled_models = await list_pulled_ollama_models()
|
||||
filtered_models = [
|
||||
pulled_model for pulled_model in pulled_models if pulled_model.name == model
|
||||
]
|
||||
if filtered_models:
|
||||
return filtered_models[0]
|
||||
except HTTPException as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to check pulled models: {e}",
|
||||
)
|
||||
|
||||
saved_model_status = REDIS_SERVICE.get(f"ollama_models/{model}")
|
||||
|
||||
# If the model is being pulled, return the model
|
||||
if saved_model_status:
|
||||
saved_model_status_json = json.loads(saved_model_status)
|
||||
# If the model is being pulled, return the model
|
||||
# ? If the model status is pulled in redis but was not found while listing pulled models,
|
||||
# ? it means the model was deleted and we need to pull it again
|
||||
if (
|
||||
saved_model_status_json["status"] == "error"
|
||||
or saved_model_status_json["status"] == "pulled"
|
||||
):
|
||||
REDIS_SERVICE.delete(f"ollama_models/{model}")
|
||||
else:
|
||||
return saved_model_status_json
|
||||
|
||||
# If the model is not being pulled, pull the model
|
||||
background_tasks.add_task(pull_ollama_model_background_task, model)
|
||||
|
||||
return OllamaModelStatus(
|
||||
name=model,
|
||||
status="pulling",
|
||||
done=False,
|
||||
)
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
from fastapi import APIRouter
|
||||
|
||||
from api.v1.ppt.endpoints.custom_llm import CUSTOM_LLM_ROUTER
|
||||
from api.v1.ppt.endpoints.files import FILES_ROUTER
|
||||
from api.v1.ppt.endpoints.icons import ICONS_ROUTER
|
||||
from api.v1.ppt.endpoints.images import IMAGES_ROUTER
|
||||
from api.v1.ppt.endpoints.ollama import OLLAMA_ROUTER
|
||||
from api.v1.ppt.endpoints.outlines import OUTLINES_ROUTER
|
||||
from api.v1.ppt.endpoints.presentation import PRESENTATION_ROUTER
|
||||
|
||||
|
|
@ -14,3 +16,5 @@ API_V1_PPT_ROUTER.include_router(OUTLINES_ROUTER)
|
|||
API_V1_PPT_ROUTER.include_router(PRESENTATION_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(IMAGES_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(ICONS_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(OLLAMA_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(CUSTOM_LLM_ROUTER)
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
from models.ollama_model_metadata import OllamaModelMetadata
|
||||
|
||||
|
||||
SUPPORTED_LLAMA_MODELS = {
|
||||
SUPPORTED_OLLAMA_MODELS = {
|
||||
"llama3:8b": OllamaModelMetadata(
|
||||
label="Llama 3:8b",
|
||||
value="llama3:8b",
|
||||
description="❌ Graphs not supported.",
|
||||
size="4.7GB",
|
||||
supports_graph=False,
|
||||
icon="/static/servers/fastapi/assets/icons/meta.png",
|
||||
icon="/static/icons/meta.png",
|
||||
),
|
||||
"llama3:70b": OllamaModelMetadata(
|
||||
label="Llama 3:70b",
|
||||
|
|
@ -16,7 +16,7 @@ SUPPORTED_LLAMA_MODELS = {
|
|||
description="✅ Graphs supported.",
|
||||
size="40GB",
|
||||
supports_graph=True,
|
||||
icon="/static/servers/fastapi/assets/icons/meta.png",
|
||||
icon="/static/icons/meta.png",
|
||||
),
|
||||
"llama3.1:8b": OllamaModelMetadata(
|
||||
label="Llama 3.1:8b",
|
||||
|
|
@ -24,7 +24,7 @@ SUPPORTED_LLAMA_MODELS = {
|
|||
description="❌ Graphs not supported.",
|
||||
size="4.9GB",
|
||||
supports_graph=False,
|
||||
icon="/static/servers/fastapi/assets/icons/meta.png",
|
||||
icon="/static/icons/meta.png",
|
||||
),
|
||||
"llama3.1:70b": OllamaModelMetadata(
|
||||
label="Llama 3.1:70b",
|
||||
|
|
@ -32,7 +32,7 @@ SUPPORTED_LLAMA_MODELS = {
|
|||
description="✅ Graphs supported.",
|
||||
size="43GB",
|
||||
supports_graph=True,
|
||||
icon="/static/servers/fastapi/assets/icons/meta.png",
|
||||
icon="/static/icons/meta.png",
|
||||
),
|
||||
"llama3.1:405b": OllamaModelMetadata(
|
||||
label="Llama 3.1:405b",
|
||||
|
|
@ -40,7 +40,7 @@ SUPPORTED_LLAMA_MODELS = {
|
|||
description="✅ Graphs supported.",
|
||||
size="243GB",
|
||||
supports_graph=True,
|
||||
icon="/static/servers/fastapi/assets/icons/meta.png",
|
||||
icon="/static/icons/meta.png",
|
||||
),
|
||||
"llama3.2:1b": OllamaModelMetadata(
|
||||
label="Llama 3.2:1b",
|
||||
|
|
@ -48,7 +48,7 @@ SUPPORTED_LLAMA_MODELS = {
|
|||
description="❌ Graphs not supported.",
|
||||
size="1.3GB",
|
||||
supports_graph=False,
|
||||
icon="/static/servers/fastapi/assets/icons/meta.png",
|
||||
icon="/static/icons/meta.png",
|
||||
),
|
||||
"llama3.2:3b": OllamaModelMetadata(
|
||||
label="Llama 3.2:3b",
|
||||
|
|
@ -56,7 +56,7 @@ SUPPORTED_LLAMA_MODELS = {
|
|||
description="❌ Graphs not supported.",
|
||||
size="2GB",
|
||||
supports_graph=False,
|
||||
icon="/static/servers/fastapi/assets/icons/meta.png",
|
||||
icon="/static/icons/meta.png",
|
||||
),
|
||||
"llama3.3:70b": OllamaModelMetadata(
|
||||
label="Llama 3.3:70b",
|
||||
|
|
@ -64,7 +64,7 @@ SUPPORTED_LLAMA_MODELS = {
|
|||
description="✅ Graphs supported.",
|
||||
size="43GB",
|
||||
supports_graph=True,
|
||||
icon="/static/servers/fastapi/assets/icons/meta.png",
|
||||
icon="/static/icons/meta.png",
|
||||
),
|
||||
"llama4:16x17b": OllamaModelMetadata(
|
||||
label="Llama 4:16x17b",
|
||||
|
|
@ -72,7 +72,7 @@ SUPPORTED_LLAMA_MODELS = {
|
|||
description="✅ Graphs supported.",
|
||||
size="67GB",
|
||||
supports_graph=True,
|
||||
icon="/static/servers/fastapi/assets/icons/meta.png",
|
||||
icon="/static/icons/meta.png",
|
||||
),
|
||||
"llama4:128x17b": OllamaModelMetadata(
|
||||
label="Llama 4:128x17b",
|
||||
|
|
@ -80,7 +80,7 @@ SUPPORTED_LLAMA_MODELS = {
|
|||
description="✅ Graphs supported.",
|
||||
size="245GB",
|
||||
supports_graph=True,
|
||||
icon="/static/servers/fastapi/assets/icons/meta.png",
|
||||
icon="/static/icons/meta.png",
|
||||
),
|
||||
}
|
||||
|
||||
|
|
@ -91,7 +91,7 @@ SUPPORTED_GEMMA_MODELS = {
|
|||
description="❌ Graphs not supported.",
|
||||
size="815MB",
|
||||
supports_graph=False,
|
||||
icon="/static/servers/fastapi/assets/icons/gemma.png",
|
||||
icon="/static/icons/gemma.png",
|
||||
),
|
||||
"gemma3:4b": OllamaModelMetadata(
|
||||
label="Gemma 3:4b",
|
||||
|
|
@ -99,7 +99,7 @@ SUPPORTED_GEMMA_MODELS = {
|
|||
description="❌ Graphs not supported.",
|
||||
size="3.3GB",
|
||||
supports_graph=False,
|
||||
icon="/static/servers/fastapi/assets/icons/gemma.png",
|
||||
icon="/static/icons/gemma.png",
|
||||
),
|
||||
"gemma3:12b": OllamaModelMetadata(
|
||||
label="Gemma 3:12b",
|
||||
|
|
@ -107,7 +107,7 @@ SUPPORTED_GEMMA_MODELS = {
|
|||
description="❌ Graphs not supported.",
|
||||
size="8.1GB",
|
||||
supports_graph=False,
|
||||
icon="/static/servers/fastapi/assets/icons/gemma.png",
|
||||
icon="/static/icons/gemma.png",
|
||||
),
|
||||
"gemma3:27b": OllamaModelMetadata(
|
||||
label="Gemma 3:27b",
|
||||
|
|
@ -115,7 +115,7 @@ SUPPORTED_GEMMA_MODELS = {
|
|||
description="✅ Graphs supported.",
|
||||
size="17GB",
|
||||
supports_graph=True,
|
||||
icon="/static/servers/fastapi/assets/icons/gemma.png",
|
||||
icon="/static/icons/gemma.png",
|
||||
),
|
||||
}
|
||||
|
||||
|
|
@ -126,7 +126,7 @@ SUPPORTED_DEEPSEEK_MODELS = {
|
|||
description="❌ Graphs not supported.",
|
||||
size="1.1GB",
|
||||
supports_graph=False,
|
||||
icon="/static/servers/fastapi/assets/icons/deepseek.png",
|
||||
icon="/static/icons/deepseek.png",
|
||||
),
|
||||
"deepseek-r1:7b": OllamaModelMetadata(
|
||||
label="DeepSeek R1:7b",
|
||||
|
|
@ -134,7 +134,7 @@ SUPPORTED_DEEPSEEK_MODELS = {
|
|||
description="❌ Graphs not supported.",
|
||||
size="4.7GB",
|
||||
supports_graph=False,
|
||||
icon="/static/servers/fastapi/assets/icons/deepseek.png",
|
||||
icon="/static/icons/deepseek.png",
|
||||
),
|
||||
"deepseek-r1:8b": OllamaModelMetadata(
|
||||
label="DeepSeek R1:8b",
|
||||
|
|
@ -142,7 +142,7 @@ SUPPORTED_DEEPSEEK_MODELS = {
|
|||
description="❌ Graphs not supported.",
|
||||
size="5.2GB",
|
||||
supports_graph=False,
|
||||
icon="/static/servers/fastapi/assets/icons/deepseek.png",
|
||||
icon="/static/icons/deepseek.png",
|
||||
),
|
||||
"deepseek-r1:14b": OllamaModelMetadata(
|
||||
label="DeepSeek R1:14b",
|
||||
|
|
@ -150,7 +150,7 @@ SUPPORTED_DEEPSEEK_MODELS = {
|
|||
description="❌ Graphs not supported.",
|
||||
size="9GB",
|
||||
supports_graph=False,
|
||||
icon="/static/servers/fastapi/assets/icons/deepseek.png",
|
||||
icon="/static/icons/deepseek.png",
|
||||
),
|
||||
"deepseek-r1:32b": OllamaModelMetadata(
|
||||
label="DeepSeek R1:32b",
|
||||
|
|
@ -158,7 +158,7 @@ SUPPORTED_DEEPSEEK_MODELS = {
|
|||
description="✅ Graphs supported.",
|
||||
size="20GB",
|
||||
supports_graph=True,
|
||||
icon="/static/servers/fastapi/assets/icons/deepseek.png",
|
||||
icon="/static/icons/deepseek.png",
|
||||
),
|
||||
"deepseek-r1:70b": OllamaModelMetadata(
|
||||
label="DeepSeek R1:70b",
|
||||
|
|
@ -166,7 +166,7 @@ SUPPORTED_DEEPSEEK_MODELS = {
|
|||
description="✅ Graphs supported.",
|
||||
size="43GB",
|
||||
supports_graph=True,
|
||||
icon="/static/servers/fastapi/assets/icons/deepseek.png",
|
||||
icon="/static/icons/deepseek.png",
|
||||
),
|
||||
"deepseek-r1:671b": OllamaModelMetadata(
|
||||
label="DeepSeek R1:671b",
|
||||
|
|
@ -174,7 +174,7 @@ SUPPORTED_DEEPSEEK_MODELS = {
|
|||
description="✅ Graphs supported.",
|
||||
size="404GB",
|
||||
supports_graph=True,
|
||||
icon="/static/servers/fastapi/assets/icons/deepseek.png",
|
||||
icon="/static/icons/deepseek.png",
|
||||
),
|
||||
}
|
||||
|
||||
|
|
@ -185,7 +185,7 @@ SUPPORTED_QWEN_MODELS = {
|
|||
description="❌ Graphs not supported.",
|
||||
size="523MB",
|
||||
supports_graph=False,
|
||||
icon="/static/servers/fastapi/assets/icons/qwen.png",
|
||||
icon="/static/icons/qwen.png",
|
||||
),
|
||||
"qwen3:1.7b": OllamaModelMetadata(
|
||||
label="Qwen 3:1.7b",
|
||||
|
|
@ -193,7 +193,7 @@ SUPPORTED_QWEN_MODELS = {
|
|||
description="❌ Graphs not supported.",
|
||||
size="1.4GB",
|
||||
supports_graph=False,
|
||||
icon="/static/servers/fastapi/assets/icons/qwen.png",
|
||||
icon="/static/icons/qwen.png",
|
||||
),
|
||||
"qwen3:4b": OllamaModelMetadata(
|
||||
label="Qwen 3:4b",
|
||||
|
|
@ -201,7 +201,7 @@ SUPPORTED_QWEN_MODELS = {
|
|||
description="❌ Graphs not supported.",
|
||||
size="2.6GB",
|
||||
supports_graph=False,
|
||||
icon="/static/servers/fastapi/assets/icons/qwen.png",
|
||||
icon="/static/icons/qwen.png",
|
||||
),
|
||||
"qwen3:8b": OllamaModelMetadata(
|
||||
label="Qwen 3:8b",
|
||||
|
|
@ -209,7 +209,7 @@ SUPPORTED_QWEN_MODELS = {
|
|||
description="❌ Graphs not supported.",
|
||||
size="5.2GB",
|
||||
supports_graph=False,
|
||||
icon="/static/servers/fastapi/assets/icons/qwen.png",
|
||||
icon="/static/icons/qwen.png",
|
||||
),
|
||||
"qwen3:14b": OllamaModelMetadata(
|
||||
label="Qwen 3:14b",
|
||||
|
|
@ -217,7 +217,7 @@ SUPPORTED_QWEN_MODELS = {
|
|||
description="❌ Graphs not supported.",
|
||||
size="9.3GB",
|
||||
supports_graph=False,
|
||||
icon="/static/servers/fastapi/assets/icons/qwen.png",
|
||||
icon="/static/icons/qwen.png",
|
||||
),
|
||||
"qwen3:30b": OllamaModelMetadata(
|
||||
label="Qwen 3:30b",
|
||||
|
|
@ -225,7 +225,7 @@ SUPPORTED_QWEN_MODELS = {
|
|||
description="✅ Graphs supported.",
|
||||
size="19GB",
|
||||
supports_graph=True,
|
||||
icon="/static/servers/fastapi/assets/icons/qwen.png",
|
||||
icon="/static/icons/qwen.png",
|
||||
),
|
||||
"qwen3:32b": OllamaModelMetadata(
|
||||
label="Qwen 3:32b",
|
||||
|
|
@ -233,7 +233,7 @@ SUPPORTED_QWEN_MODELS = {
|
|||
description="✅ Graphs supported.",
|
||||
size="20GB",
|
||||
supports_graph=True,
|
||||
icon="/static/servers/fastapi/assets/icons/qwen.png",
|
||||
icon="/static/icons/qwen.png",
|
||||
),
|
||||
"qwen3:235b": OllamaModelMetadata(
|
||||
label="Qwen 3:235b",
|
||||
|
|
@ -241,12 +241,12 @@ SUPPORTED_QWEN_MODELS = {
|
|||
description="✅ Graphs supported.",
|
||||
size="142GB",
|
||||
supports_graph=True,
|
||||
icon="/static/servers/fastapi/assets/icons/qwen.png",
|
||||
icon="/static/icons/qwen.png",
|
||||
),
|
||||
}
|
||||
|
||||
SUPPORTED_OLLAMA_MODELS = {
|
||||
**SUPPORTED_LLAMA_MODELS,
|
||||
**SUPPORTED_OLLAMA_MODELS,
|
||||
**SUPPORTED_GEMMA_MODELS,
|
||||
**SUPPORTED_DEEPSEEK_MODELS,
|
||||
**SUPPORTED_QWEN_MODELS,
|
||||
|
|
|
|||
7
servers/fastapi/enums/image_provider.py
Normal file
7
servers/fastapi/enums/image_provider.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
from enum import Enum
|
||||
|
||||
class ImageProvider(Enum):
|
||||
PEXELS = "pexels"
|
||||
PIXABAY = "pixabay"
|
||||
GEMINI_FLASH = "gemini_flash"
|
||||
DALLE3 = "dall-e-3"
|
||||
|
|
@ -144,6 +144,7 @@ class PptxConnectorModel(PptxShapeModel):
|
|||
|
||||
|
||||
class PptxSlideModel(BaseModel):
|
||||
background: Optional[PptxFillModel] = None
|
||||
shapes: List[
|
||||
PptxTextBoxModel
|
||||
| PptxAutoShapeBoxModel
|
||||
|
|
|
|||
|
|
@ -12,3 +12,5 @@ class UserConfig(BaseModel):
|
|||
CUSTOM_LLM_API_KEY: Optional[str] = None
|
||||
CUSTOM_MODEL: Optional[str] = None
|
||||
PEXELS_API_KEY: Optional[str] = None
|
||||
IMAGE_PROVIDER: Optional[str] = None
|
||||
PIXABAY_API_KEY: Optional[str] = None
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ aiohttp==3.12.14
|
|||
aiosignal==1.4.0
|
||||
annotated-types==0.7.0
|
||||
anyio==4.9.0
|
||||
async-timeout==5.0.1
|
||||
attrs==25.3.0
|
||||
cachetools==5.5.2
|
||||
certifi==2025.7.14
|
||||
|
|
@ -55,6 +56,7 @@ python-dotenv==1.1.1
|
|||
python-multipart==0.0.20
|
||||
python-pptx==1.0.2
|
||||
PyYAML==6.0.2
|
||||
redis==6.2.0
|
||||
requests==2.32.4
|
||||
rich==14.0.0
|
||||
rich-toolkit==0.14.8
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
from services.redis_service import RedisService
|
||||
from services.temp_file_service import TempFileService
|
||||
from services.database import sql_engine
|
||||
|
||||
|
||||
TEMP_FILE_SERVICE = TempFileService()
|
||||
SQL_ENGINE = sql_engine
|
||||
REDIS_SERVICE = RedisService()
|
||||
|
|
|
|||
|
|
@ -8,10 +8,13 @@ from models.image_prompt import ImagePrompt
|
|||
from models.sql.image_asset import ImageAsset
|
||||
from utils.download_helpers import download_file
|
||||
from utils.get_env import get_pexels_api_key_env
|
||||
from utils.llm_provider import (
|
||||
get_llm_client,
|
||||
is_google_selected,
|
||||
is_openai_selected,
|
||||
from utils.get_env import get_pixabay_api_key_env
|
||||
from utils.llm_provider import get_llm_client
|
||||
from utils.image_provider import (
|
||||
is_pixels_selected,
|
||||
is_pixabay_selected,
|
||||
is_gemini_flash_selected,
|
||||
is_dalle3_selected,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -19,32 +22,46 @@ class ImageGenerationService:
|
|||
|
||||
def __init__(self, output_directory: str):
|
||||
self.output_directory = output_directory
|
||||
|
||||
self.use_pexels = False
|
||||
if get_pexels_api_key_env():
|
||||
self.use_pexels = True
|
||||
|
||||
self.image_gen_func = self.get_image_gen_func()
|
||||
|
||||
def get_image_gen_func(self):
|
||||
if self.use_pexels:
|
||||
if is_pixabay_selected():
|
||||
return self.get_image_from_pixabay
|
||||
elif is_pixels_selected():
|
||||
return self.get_image_from_pexels
|
||||
elif is_google_selected():
|
||||
elif is_gemini_flash_selected():
|
||||
return self.generate_image_google
|
||||
elif is_openai_selected():
|
||||
elif is_dalle3_selected():
|
||||
return self.generate_image_openai
|
||||
return None
|
||||
|
||||
def is_stock_provider_selected(self):
|
||||
return is_pixels_selected() or is_pixabay_selected()
|
||||
|
||||
async def generate_image(self, prompt: ImagePrompt) -> str | ImageAsset:
|
||||
"""
|
||||
Generates an image based on the provided prompt.
|
||||
- If no image generation function is available, returns a placeholder image.
|
||||
- If the stock provider is selected, it uses the prompt directly,
|
||||
otherwise it uses the full image prompt with theme.
|
||||
- Output Directory is used for saving the generated image not the stock provider.
|
||||
"""
|
||||
if not self.image_gen_func:
|
||||
print("No image generation function found. Using placeholder image.")
|
||||
return "/static/images/placeholder.jpg"
|
||||
|
||||
image_prompt = prompt.get_image_prompt(not self.use_pexels)
|
||||
image_prompt = prompt.get_image_prompt(
|
||||
with_theme=not self.is_stock_provider_selected()
|
||||
)
|
||||
print(f"Request - Generating Image for {image_prompt}")
|
||||
|
||||
try:
|
||||
image_path = await self.image_gen_func(image_prompt, self.output_directory)
|
||||
if self.is_stock_provider_selected():
|
||||
image_path = await self.image_gen_func(image_prompt)
|
||||
else:
|
||||
image_path = await self.image_gen_func(
|
||||
image_prompt, self.output_directory
|
||||
)
|
||||
if image_path:
|
||||
if image_path.startswith("http"):
|
||||
return image_path
|
||||
|
|
@ -102,3 +119,12 @@ class ImageGenerationService:
|
|||
data = await response.json()
|
||||
image_url = data["photos"][0]["src"]["large"]
|
||||
return image_url
|
||||
|
||||
async def get_image_from_pixabay(self, prompt: str) -> str:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
response = await session.get(
|
||||
f"https://pixabay.com/api/?key={get_pixabay_api_key_env()}&q={prompt}&image_type=photo&per_page=1"
|
||||
)
|
||||
data = await response.json()
|
||||
image_url = data["hits"][0]["largeImageURL"]
|
||||
return image_url
|
||||
|
|
|
|||
|
|
@ -108,6 +108,9 @@ class PptxPresentationCreator:
|
|||
def add_and_populate_slide(self, slide_model: PptxSlideModel):
|
||||
slide = self._ppt.slides.add_slide(self._ppt.slide_layouts[BLANK_SLIDE_LAYOUT])
|
||||
|
||||
if slide_model.background:
|
||||
self.apply_fill_to_shape(slide.background, slide_model.background)
|
||||
|
||||
for shape_model in slide_model.shapes:
|
||||
model_type = type(shape_model)
|
||||
|
||||
|
|
|
|||
115
servers/fastapi/services/redis_service.py
Normal file
115
servers/fastapi/services/redis_service.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
from typing import Any, Optional
|
||||
import redis
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
from utils.get_env import (
|
||||
get_redis_db_env,
|
||||
get_redis_host_env,
|
||||
get_redis_password_env,
|
||||
get_redis_port_env,
|
||||
)
|
||||
|
||||
|
||||
class RedisService:
|
||||
def __init__(self):
|
||||
self.redis_host = get_redis_host_env() or "localhost"
|
||||
self.redis_port = int(get_redis_port_env() or "6379")
|
||||
self.redis_db = int(get_redis_db_env() or "0")
|
||||
self.redis_password = get_redis_password_env() or None
|
||||
self.client = self._create_client()
|
||||
|
||||
def _create_client(self) -> redis.Redis:
|
||||
return redis.Redis(
|
||||
host=self.redis_host,
|
||||
port=self.redis_port,
|
||||
db=self.redis_db,
|
||||
password=self.redis_password,
|
||||
decode_responses=True,
|
||||
)
|
||||
|
||||
def set(self, key: str, value: Any, expire: Optional[int] = None) -> bool:
|
||||
try:
|
||||
return self.client.set(key, value, ex=expire)
|
||||
except RedisError:
|
||||
return False
|
||||
|
||||
def get(self, key: str) -> Optional[str]:
|
||||
try:
|
||||
return self.client.get(key)
|
||||
except RedisError:
|
||||
return None
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
try:
|
||||
return bool(self.client.delete(key))
|
||||
except RedisError:
|
||||
return False
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
try:
|
||||
return bool(self.client.exists(key))
|
||||
except RedisError:
|
||||
return False
|
||||
|
||||
def set_hash(self, name: str, mapping: dict) -> bool:
|
||||
try:
|
||||
return self.client.hmset(name, mapping)
|
||||
except RedisError:
|
||||
return False
|
||||
|
||||
def get_hash(self, name: str) -> Optional[dict]:
|
||||
try:
|
||||
return self.client.hgetall(name)
|
||||
except RedisError:
|
||||
return None
|
||||
|
||||
def delete_hash(self, name: str, *fields: str) -> int:
|
||||
try:
|
||||
return self.client.hdel(name, *fields)
|
||||
except RedisError:
|
||||
return 0
|
||||
|
||||
def set_list(self, name: str, values: list) -> bool:
|
||||
try:
|
||||
self.client.delete(name)
|
||||
if values:
|
||||
self.client.rpush(name, *values)
|
||||
return True
|
||||
except RedisError:
|
||||
return False
|
||||
|
||||
def get_list(self, name: str, start: int = 0, end: int = -1) -> Optional[list]:
|
||||
try:
|
||||
return self.client.lrange(name, start, end)
|
||||
except RedisError:
|
||||
return None
|
||||
|
||||
def add_to_set(self, name: str, *values: str) -> int:
|
||||
try:
|
||||
return self.client.sadd(name, *values)
|
||||
except RedisError:
|
||||
return 0
|
||||
|
||||
def get_set(self, name: str) -> Optional[set]:
|
||||
try:
|
||||
return self.client.smembers(name)
|
||||
except RedisError:
|
||||
return None
|
||||
|
||||
def remove_from_set(self, name: str, *values: str) -> int:
|
||||
try:
|
||||
return self.client.srem(name, *values)
|
||||
except RedisError:
|
||||
return 0
|
||||
|
||||
def clear(self) -> bool:
|
||||
try:
|
||||
return self.client.flushdb()
|
||||
except RedisError:
|
||||
return False
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
self.client.close()
|
||||
except RedisError:
|
||||
pass
|
||||
|
|
@ -2,11 +2,13 @@ import os
|
|||
import uuid
|
||||
from typing import Optional, Union
|
||||
|
||||
from utils.get_env import get_temp_directory_env
|
||||
|
||||
|
||||
class TempFileService:
|
||||
|
||||
def __init__(self):
|
||||
self.base_dir = os.getenv("TEMP_DIRECTORY")
|
||||
self.base_dir = get_temp_directory_env()
|
||||
# TODO: Uncomment this when we want to cleanup the base dir on startup
|
||||
# self.cleanup_base_dir()
|
||||
os.makedirs(self.base_dir, exist_ok=True)
|
||||
|
|
|
|||
400
servers/fastapi/tests/test_image_generation.py
Normal file
400
servers/fastapi/tests/test_image_generation.py
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
import pytest
|
||||
import asyncio
|
||||
import os
|
||||
from unittest.mock import Mock, patch, AsyncMock
|
||||
import httpx
|
||||
from fastapi.testclient import TestClient
|
||||
from fastapi import FastAPI
|
||||
from api.v1.ppt.endpoints.images import IMAGES_ROUTER
|
||||
from models.image_prompt import ImagePrompt
|
||||
from services.image_generation_service import ImageGenerationService
|
||||
from models.sql.image_asset import ImageAsset
|
||||
|
||||
|
||||
class TestImageGenerationService:
|
||||
"""
|
||||
Testing the image Generation Service
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_images_directory(self, tmp_path):
|
||||
"""
|
||||
Creates new images directory for every test case we run
|
||||
"""
|
||||
images_dir = tmp_path / "images"
|
||||
images_dir.mkdir()
|
||||
return str(images_dir)
|
||||
|
||||
@pytest.fixture
|
||||
def sample_image_prompt(self):
|
||||
"""
|
||||
Creates a sample ImagePrompt for testing
|
||||
"""
|
||||
return ImagePrompt(prompt="A beautiful sunset over mountains")
|
||||
|
||||
def test_image_generation_service_initialization(self, mock_images_directory):
|
||||
"""
|
||||
Test initialization of ImageGenerationService with output directory
|
||||
- Checks if the output directory is set correctly
|
||||
- Checks if the image generation function is set based on environment variable
|
||||
"""
|
||||
with patch.dict(os.environ, {"IMAGE_PROVIDER": "pexels"}):
|
||||
service = ImageGenerationService(mock_images_directory)
|
||||
assert service.output_directory == mock_images_directory
|
||||
assert service.image_gen_func is not None or service.image_gen_func is None
|
||||
|
||||
def test_get_image_gen_func_pixabay_selected(self, mock_images_directory):
|
||||
"""
|
||||
Testing the function selection when Pixabay is selected
|
||||
- Checks if the correct function is selected based on environment variable
|
||||
- Ensures that the function is set to get_image_from_pixabay when Pixabay is selected
|
||||
"""
|
||||
with patch('services.image_generation_service.is_pixabay_selected', return_value=True):
|
||||
with patch('services.image_generation_service.is_pixels_selected', return_value=False):
|
||||
with patch('services.image_generation_service.is_gemini_flash_selected', return_value=False):
|
||||
with patch('services.image_generation_service.is_dalle3_selected', return_value=False):
|
||||
with patch.dict(os.environ, {"IMAGE_PROVIDER": "pixabay"}):
|
||||
service = ImageGenerationService(mock_images_directory)
|
||||
assert service.image_gen_func == service.get_image_from_pixabay
|
||||
|
||||
def test_get_image_gen_func_pexels_selected(self, mock_images_directory):
|
||||
"""
|
||||
Test function selection when Pexels is selected
|
||||
- Checks if the correct function is selected based on environment variable
|
||||
- Ensures that the function is set to get_image_from_pexels when Pexels is selected
|
||||
"""
|
||||
with patch('services.image_generation_service.is_pixabay_selected', return_value=False):
|
||||
with patch('services.image_generation_service.is_pixels_selected', return_value=True):
|
||||
with patch('services.image_generation_service.is_gemini_flash_selected', return_value=False):
|
||||
with patch('services.image_generation_service.is_dalle3_selected', return_value=False):
|
||||
with patch.dict(os.environ, {"IMAGE_PROVIDER": "pexels"}):
|
||||
service = ImageGenerationService(mock_images_directory)
|
||||
assert service.image_gen_func == service.get_image_from_pexels
|
||||
|
||||
def test_get_image_gen_func_dalle3_selected(self, mock_images_directory):
|
||||
"""
|
||||
Test function selection when DALL-E 3 is selected
|
||||
- Checks if the correct function is selected based on environment variable
|
||||
- Ensures that the function is set to generate_image_openai when DALL-E 3 is selected
|
||||
"""
|
||||
with patch('services.image_generation_service.is_pixabay_selected', return_value=False):
|
||||
with patch('services.image_generation_service.is_pixels_selected', return_value=False):
|
||||
with patch('services.image_generation_service.is_gemini_flash_selected', return_value=False):
|
||||
with patch('services.image_generation_service.is_dalle3_selected', return_value=True):
|
||||
with patch.dict(os.environ, {"IMAGE_PROVIDER": "dall-e-3"}):
|
||||
service = ImageGenerationService(mock_images_directory)
|
||||
assert service.image_gen_func == service.generate_image_openai
|
||||
|
||||
def test_is_stock_provider_selected(self, mock_images_directory):
|
||||
"""
|
||||
Test if stock provider is selected based on environment variable
|
||||
- Checks if the stock provider is selected correctly based on environment variable
|
||||
- Ensures that is_stock_provider_selected returns True for Pexels or Pixabay
|
||||
"""
|
||||
with patch('services.image_generation_service.is_pixels_selected', return_value=True):
|
||||
with patch('services.image_generation_service.is_pixabay_selected', return_value=False):
|
||||
with patch.dict(os.environ, {"IMAGE_PROVIDER": "pexels"}):
|
||||
service = ImageGenerationService(mock_images_directory)
|
||||
assert service.is_stock_provider_selected() is True
|
||||
|
||||
with patch('services.image_generation_service.is_pixels_selected', return_value=False):
|
||||
with patch('services.image_generation_service.is_pixabay_selected', return_value=True):
|
||||
with patch.dict(os.environ, {"IMAGE_PROVIDER": "pixabay"}):
|
||||
service = ImageGenerationService(mock_images_directory)
|
||||
assert service.is_stock_provider_selected() is True
|
||||
|
||||
with patch('services.image_generation_service.is_pixels_selected', return_value=False):
|
||||
with patch('services.image_generation_service.is_pixabay_selected', return_value=False):
|
||||
with patch.dict(os.environ, {"IMAGE_PROVIDER": "dall-e-3"}):
|
||||
service = ImageGenerationService(mock_images_directory)
|
||||
assert service.is_stock_provider_selected() is False
|
||||
|
||||
def test_generate_image_with_pexels_success(self, mock_images_directory, sample_image_prompt):
|
||||
"""
|
||||
Test successful image generation with Pexels provider
|
||||
- Mocks the Pexels API to return a valid image URL
|
||||
- Ensures that the image generation function returns the expected URL
|
||||
- Checks if the image generation function is called with the correct prompt
|
||||
"""
|
||||
async def run_test():
|
||||
with patch.dict(os.environ, {"IMAGE_PROVIDER": "pexels", "PEXELS_API_KEY": "test_key"}):
|
||||
with patch('services.image_generation_service.is_pixels_selected', return_value=True):
|
||||
with patch('services.image_generation_service.is_pixabay_selected', return_value=False):
|
||||
with patch('services.image_generation_service.is_gemini_flash_selected', return_value=False):
|
||||
with patch('services.image_generation_service.is_dalle3_selected', return_value=False):
|
||||
service = ImageGenerationService(mock_images_directory)
|
||||
|
||||
mock_response = AsyncMock()
|
||||
mock_response.json = AsyncMock(return_value={
|
||||
"photos": [{
|
||||
"src": {
|
||||
"large": "https://example.com/image.jpg"
|
||||
}
|
||||
}]
|
||||
})
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.get = AsyncMock(return_value=mock_response)
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with patch('aiohttp.ClientSession', return_value=mock_session):
|
||||
result = await service.generate_image(sample_image_prompt)
|
||||
assert result == "https://example.com/image.jpg"
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
def test_generate_image_with_dalle3_success(self, mock_images_directory, sample_image_prompt):
|
||||
"""
|
||||
Test successful image generation with DALL-E 3 provider
|
||||
- Mocks the OpenAI client to return a valid image URL
|
||||
- Ensures that the image generation function returns the expected URL
|
||||
- Checks if the image generation function is called with the correct prompt
|
||||
"""
|
||||
async def run_test():
|
||||
with patch.dict(os.environ, {"IMAGE_PROVIDER": "dall-e-3"}):
|
||||
with patch('services.image_generation_service.is_pixels_selected', return_value=False):
|
||||
with patch('services.image_generation_service.is_pixabay_selected', return_value=False):
|
||||
with patch('services.image_generation_service.is_gemini_flash_selected', return_value=False):
|
||||
with patch('services.image_generation_service.is_dalle3_selected', return_value=True):
|
||||
service = ImageGenerationService(mock_images_directory)
|
||||
|
||||
# Create a real test file
|
||||
test_image_path = f"{mock_images_directory}/test_image.jpg"
|
||||
with open(test_image_path, 'w') as f:
|
||||
f.write("fake image content")
|
||||
|
||||
# Mock generate_image_openai to return the test file path
|
||||
async def mock_openai_generate(prompt, output_dir):
|
||||
return test_image_path
|
||||
|
||||
service.generate_image_openai = mock_openai_generate
|
||||
|
||||
result = await service.generate_image(sample_image_prompt)
|
||||
|
||||
# Should return ImageAsset for AI providers
|
||||
assert isinstance(result, ImageAsset)
|
||||
assert result.path == test_image_path
|
||||
assert result.extras["prompt"] == sample_image_prompt.prompt
|
||||
|
||||
def test_generate_image_no_provider_selected(self, mock_images_directory, sample_image_prompt):
|
||||
"""
|
||||
Test generate_image when no provider is selected
|
||||
- Mocks the environment variable to simulate no provider selected
|
||||
- Ensures that the function returns a placeholder image path
|
||||
- Checks if the image generation function is called with the correct prompt
|
||||
"""
|
||||
async def run_test():
|
||||
with patch('services.image_generation_service.is_pixels_selected', return_value=False):
|
||||
with patch('services.image_generation_service.is_pixabay_selected', return_value=False):
|
||||
with patch('services.image_generation_service.is_gemini_flash_selected', return_value=False):
|
||||
with patch('services.image_generation_service.is_dalle3_selected', return_value=False):
|
||||
with patch.dict(os.environ, {"IMAGE_PROVIDER": "pexels"}):
|
||||
service = ImageGenerationService(mock_images_directory)
|
||||
|
||||
result = await service.generate_image(sample_image_prompt)
|
||||
|
||||
# Should return placeholder
|
||||
assert result == "/static/images/placeholder.jpg"
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
def test_generate_image_provider_error(self, mock_images_directory, sample_image_prompt):
|
||||
"""
|
||||
Test generate_image when provider function raises an error
|
||||
- Mocks the Pexels API to raise an exception
|
||||
- Ensures that the function returns a placeholder image path
|
||||
- Checks if the image generation function is called with the correct prompt
|
||||
"""
|
||||
async def run_test():
|
||||
with patch('services.image_generation_service.is_pixels_selected', return_value=True):
|
||||
with patch('services.image_generation_service.is_pixabay_selected', return_value=False):
|
||||
with patch('services.image_generation_service.is_gemini_flash_selected', return_value=False):
|
||||
with patch('services.image_generation_service.is_dalle3_selected', return_value=False):
|
||||
with patch.dict(os.environ, {"IMAGE_PROVIDER": "pexels"}):
|
||||
service = ImageGenerationService(mock_images_directory)
|
||||
|
||||
async def mock_pexels_error(*args, **kwargs):
|
||||
raise Exception("API Error")
|
||||
|
||||
service.get_image_from_pexels = mock_pexels_error
|
||||
|
||||
result = await service.generate_image(sample_image_prompt)
|
||||
|
||||
assert result == "/static/images/placeholder.jpg"
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
def test_get_image_from_pexels_real_function(self, mock_images_directory):
|
||||
"""T
|
||||
Test REAL Pexels function with mocked HTTP call
|
||||
- Mocks the Pexels API to return a valid image URL
|
||||
- Ensures that the function returns the expected URL
|
||||
- Checks if the HTTP call is made with the correct parameters
|
||||
"""
|
||||
async def run_test():
|
||||
with patch.dict(os.environ, {"IMAGE_PROVIDER": "pexels", "PEXELS_API_KEY": "test_pexels_key"}):
|
||||
service = ImageGenerationService(mock_images_directory)
|
||||
|
||||
mock_response = AsyncMock()
|
||||
mock_response.json = AsyncMock(return_value={
|
||||
"photos": [{
|
||||
"src": {
|
||||
"large": "https://example.com/pexels_image.jpg"
|
||||
}
|
||||
}]
|
||||
})
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.get = AsyncMock(return_value=mock_response)
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with patch('aiohttp.ClientSession', return_value=mock_session):
|
||||
result = await service.get_image_from_pexels("sunset")
|
||||
|
||||
assert result == "https://example.com/pexels_image.jpg"
|
||||
mock_session.get.assert_called_once()
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
def test_get_image_from_pixabay_real_function(self, mock_images_directory):
|
||||
"""
|
||||
Test REAL Pixabay function with mocked HTTP call
|
||||
- Mocks the Pixabay API to return a valid image URL
|
||||
- Ensures that the function returns the expected URL
|
||||
- Checks if the HTTP call is made with the correct parameters
|
||||
"""
|
||||
async def run_test():
|
||||
with patch.dict(os.environ, {"IMAGE_PROVIDER": "pixabay", "PIXABAY_API_KEY": "test_pixabay_key"}):
|
||||
service = ImageGenerationService(mock_images_directory)
|
||||
|
||||
mock_response = AsyncMock()
|
||||
mock_response.json = AsyncMock(return_value={
|
||||
"hits": [{
|
||||
"largeImageURL": "https://example.com/pixabay_image.jpg"
|
||||
}]
|
||||
})
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.get = AsyncMock(return_value=mock_response)
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with patch('aiohttp.ClientSession', return_value=mock_session):
|
||||
result = await service.get_image_from_pixabay("sunset")
|
||||
|
||||
assert result == "https://example.com/pixabay_image.jpg"
|
||||
mock_session.get.assert_called_once()
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
|
||||
class TestImageGenerationEndpoint:
|
||||
"""
|
||||
Testing the Image Generation API Endpoint
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def app(self):
|
||||
"""Create FastAPI app with the images router"""
|
||||
app = FastAPI()
|
||||
app.include_router(IMAGES_ROUTER)
|
||||
return app
|
||||
|
||||
@pytest.fixture
|
||||
def client(self, app):
|
||||
"""Create test client"""
|
||||
return TestClient(app)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_images_directory(self, tmp_path):
|
||||
"""Mock images directory"""
|
||||
images_dir = tmp_path / "images"
|
||||
images_dir.mkdir()
|
||||
return str(images_dir)
|
||||
|
||||
def test_generate_image_endpoint_success_stock_provider(self, client, mock_images_directory):
|
||||
"""
|
||||
Test successful image generation via API endpoint with stock provider
|
||||
- Mocks the ImageGenerationService to return a stock image URL
|
||||
- Ensures that the endpoint returns the expected URL
|
||||
- Checks if the image generation function is called with the correct prompt
|
||||
"""
|
||||
test_prompt = "A beautiful sunset over mountains"
|
||||
|
||||
with patch('api.v1.ppt.endpoints.images.get_images_directory', return_value=mock_images_directory):
|
||||
with patch('api.v1.ppt.endpoints.images.ImageGenerationService') as mock_service_class:
|
||||
mock_service_instance = Mock()
|
||||
mock_service_instance.generate_image = AsyncMock(return_value="https://example.com/stock_image.jpg")
|
||||
mock_service_class.return_value = mock_service_instance
|
||||
response = client.get(f"/images/generate?prompt={test_prompt}")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_generate_image_endpoint_success_ai_provider(self, client, mock_images_directory):
|
||||
"""
|
||||
Test successful image generation via API endpoint with AI provider
|
||||
- Mocks the ImageGenerationService to return an ImageAsset object
|
||||
- Ensures that the endpoint returns the expected ImageAsset object
|
||||
- Checks if the image generation function is called with the correct prompt
|
||||
"""
|
||||
test_prompt = "A beautiful sunset over mountains"
|
||||
|
||||
test_image_asset = ImageAsset(
|
||||
path=f"{mock_images_directory}/test_image.jpg",
|
||||
extras={"prompt": test_prompt, "theme_prompt": "professional"}
|
||||
)
|
||||
|
||||
with patch('api.v1.ppt.endpoints.images.get_images_directory', return_value=mock_images_directory):
|
||||
with patch('api.v1.ppt.endpoints.images.ImageGenerationService') as mock_service_class:
|
||||
mock_service_instance = Mock()
|
||||
mock_service_instance.generate_image = AsyncMock(return_value=test_image_asset)
|
||||
mock_service_class.return_value = mock_service_instance
|
||||
|
||||
response = client.get(f"/images/generate?prompt={test_prompt}")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_generate_image_endpoint_placeholder_response(self, client, mock_images_directory):
|
||||
"""
|
||||
Test endpoint returns placeholder image when no provider is selected
|
||||
- Mocks the ImageGenerationService to return a placeholder image path
|
||||
- Ensures that the endpoint returns the placeholder image path
|
||||
- Checks if the image generation function is called with the correct prompt
|
||||
"""
|
||||
test_prompt = "Test prompt"
|
||||
|
||||
with patch('api.v1.ppt.endpoints.images.get_images_directory', return_value=mock_images_directory):
|
||||
with patch('api.v1.ppt.endpoints.images.ImageGenerationService') as mock_service_class:
|
||||
mock_service_instance = Mock()
|
||||
mock_service_instance.generate_image = AsyncMock(return_value="/static/images/placeholder.jpg")
|
||||
mock_service_class.return_value = mock_service_instance
|
||||
|
||||
response = client.get(f"/images/generate?prompt={test_prompt}")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_generate_image_endpoint_with_async_client(self, mock_images_directory):
|
||||
"""
|
||||
Test the image generation endpoint using an async client
|
||||
- Mocks the ImageGenerationService to return a valid image URL
|
||||
- Ensures that the endpoint returns the expected URL
|
||||
- Checks if the image generation function is called with the correct prompt
|
||||
"""
|
||||
async def run_test():
|
||||
app = FastAPI()
|
||||
app.include_router(IMAGES_ROUTER)
|
||||
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
with patch('api.v1.ppt.endpoints.images.get_images_directory', return_value=mock_images_directory):
|
||||
with patch('api.v1.ppt.endpoints.images.ImageGenerationService') as mock_service_class:
|
||||
mock_service_instance = Mock()
|
||||
mock_service_instance.generate_image = AsyncMock(return_value="https://example.com/image.jpg")
|
||||
mock_service_class.return_value = mock_service_instance
|
||||
|
||||
response = await ac.get("/images/generate?prompt=test")
|
||||
assert response.status_code == 200
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
|
|
@ -55,3 +55,27 @@ def get_custom_model_env():
|
|||
|
||||
def get_pexels_api_key_env():
|
||||
return os.getenv("PEXELS_API_KEY")
|
||||
|
||||
|
||||
def get_image_provider_env():
|
||||
return os.getenv("IMAGE_PROVIDER")
|
||||
|
||||
|
||||
def get_pixabay_api_key_env():
|
||||
return os.getenv("PIXABAY_API_KEY")
|
||||
|
||||
|
||||
def get_redis_host_env():
|
||||
return os.getenv("REDIS_HOST")
|
||||
|
||||
|
||||
def get_redis_port_env():
|
||||
return os.getenv("REDIS_PORT")
|
||||
|
||||
|
||||
def get_redis_db_env():
|
||||
return os.getenv("REDIS_DB")
|
||||
|
||||
|
||||
def get_redis_password_env():
|
||||
return os.getenv("REDIS_PASSWORD")
|
||||
|
|
|
|||
47
servers/fastapi/utils/image_provider.py
Normal file
47
servers/fastapi/utils/image_provider.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
from enums.image_provider import ImageProvider
|
||||
from utils.get_env import (
|
||||
get_google_api_key_env,
|
||||
get_image_provider_env,
|
||||
get_openai_api_key_env,
|
||||
get_pexels_api_key_env,
|
||||
get_pixabay_api_key_env,
|
||||
)
|
||||
|
||||
|
||||
def is_pixels_selected() -> bool:
|
||||
return ImageProvider.PEXELS == get_selected_image_provider()
|
||||
|
||||
|
||||
def is_pixabay_selected() -> bool:
|
||||
return ImageProvider.PIXABAY == get_selected_image_provider()
|
||||
|
||||
|
||||
def is_gemini_flash_selected() -> bool:
|
||||
return ImageProvider.GEMINI_FLASH == get_selected_image_provider()
|
||||
|
||||
|
||||
def is_dalle3_selected() -> bool:
|
||||
return ImageProvider.DALLE3 == get_selected_image_provider()
|
||||
|
||||
|
||||
def get_selected_image_provider() -> ImageProvider:
|
||||
"""
|
||||
Get the selected image provider from environment variables.
|
||||
Returns:
|
||||
ImageProvider: The selected image provider.
|
||||
"""
|
||||
return ImageProvider(get_image_provider_env())
|
||||
|
||||
|
||||
def get_image_provider_api_key() -> str:
|
||||
selected_image_provider = get_selected_image_provider()
|
||||
if selected_image_provider == ImageProvider.PEXELS:
|
||||
return get_pexels_api_key_env()
|
||||
elif selected_image_provider == ImageProvider.PIXABAY:
|
||||
return get_pixabay_api_key_env()
|
||||
elif selected_image_provider == ImageProvider.GEMINI_FLASH:
|
||||
return get_google_api_key_env()
|
||||
elif selected_image_provider == ImageProvider.DALLE3:
|
||||
return get_openai_api_key_env()
|
||||
else:
|
||||
raise ValueError(f"Invalid image provider: {selected_image_provider}")
|
||||
|
|
@ -11,29 +11,7 @@ from utils.llm_provider import (
|
|||
is_google_selected,
|
||||
)
|
||||
|
||||
# system_prompt = """
|
||||
# Create a presentation based on the provided prompt, number of slides, output language, and additional informational details.
|
||||
# Format the output in the specified JSON schema with structured markdown content.
|
||||
|
||||
# # Steps
|
||||
|
||||
# 1. Identify key points from the provided prompt, including the topic, number of slides, output language, and additional content directions.
|
||||
# 2. Create a concise and descriptive title reflecting the main topic, adhering to the specified language.
|
||||
# 3. Generate a clear title for each slide.
|
||||
# 4. Develop comprehensive content using markdown structure:
|
||||
# * Use bullet points (- or *) for lists.
|
||||
# * Use **bold** for emphasis, *italic* for secondary emphasis, and `code` for technical terms.
|
||||
# 5. Provide important points from prompt as notes.
|
||||
|
||||
# # Notes
|
||||
# - Content must be generated for every slide.
|
||||
# - Images or Icons information provided in **Input** must be included in the **notes**.
|
||||
# - Notes should cleary define if it is for specific slide or for the presentation.
|
||||
# - Slide **body** should not contain slide **title**.
|
||||
# - Slide **title** should not contain "Slide 1", "Slide 2", etc.
|
||||
# - Slide **title** should not be in markdown format.
|
||||
# - There must be exact **Number of Slides** as specified.
|
||||
# """
|
||||
system_prompt = """
|
||||
You are an expert presentation creator. Generate structured presentations based on user requirements and format them according to the specified JSON schema with markdown content.
|
||||
|
||||
|
|
@ -183,13 +161,7 @@ async def generate_ppt_outline(
|
|||
async with client.beta.chat.completions.stream(
|
||||
model=model,
|
||||
messages=get_prompt_template(prompt, n_slides, language, content),
|
||||
response_format={
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "PresentationOutline",
|
||||
"schema": response_model.model_json_schema(),
|
||||
},
|
||||
},
|
||||
response_format=response_model,
|
||||
) as stream:
|
||||
async for event in stream:
|
||||
if isinstance(event, ContentDeltaEvent):
|
||||
|
|
|
|||
|
|
@ -7,8 +7,10 @@ from enums.llm_provider import LLMProvider
|
|||
from utils.get_env import (
|
||||
get_custom_llm_api_key_env,
|
||||
get_custom_llm_url_env,
|
||||
get_custom_model_env,
|
||||
get_google_api_key_env,
|
||||
get_llm_provider_env,
|
||||
get_ollama_model_env,
|
||||
get_ollama_url_env,
|
||||
get_openai_api_key_env,
|
||||
)
|
||||
|
|
@ -93,9 +95,9 @@ def get_large_model():
|
|||
elif selected_llm == LLMProvider.GOOGLE:
|
||||
return "gemini-2.0-flash"
|
||||
elif selected_llm == LLMProvider.OLLAMA:
|
||||
return os.getenv("OLLAMA_MODEL")
|
||||
return get_ollama_model_env()
|
||||
elif selected_llm == LLMProvider.CUSTOM:
|
||||
return os.getenv("CUSTOM_MODEL")
|
||||
return get_custom_model_env()
|
||||
else:
|
||||
raise ValueError(f"Invalid LLM model")
|
||||
|
||||
|
|
@ -107,9 +109,9 @@ def get_small_model():
|
|||
elif selected_llm == LLMProvider.GOOGLE:
|
||||
return "gemini-2.0-flash"
|
||||
elif selected_llm == LLMProvider.OLLAMA:
|
||||
return os.getenv("OLLAMA_MODEL")
|
||||
return get_ollama_model_env()
|
||||
elif selected_llm == LLMProvider.CUSTOM:
|
||||
return os.getenv("CUSTOM_MODEL")
|
||||
return get_custom_model_env()
|
||||
else:
|
||||
raise ValueError(f"Invalid LLM model")
|
||||
|
||||
|
|
@ -121,8 +123,8 @@ def get_nano_model():
|
|||
elif selected_llm == LLMProvider.GOOGLE:
|
||||
return "gemini-2.0-flash"
|
||||
elif selected_llm == LLMProvider.OLLAMA:
|
||||
return os.getenv("OLLAMA_MODEL")
|
||||
return get_ollama_model_env()
|
||||
elif selected_llm == LLMProvider.CUSTOM:
|
||||
return os.getenv("CUSTOM_MODEL")
|
||||
return get_custom_model_env()
|
||||
else:
|
||||
raise ValueError(f"Invalid LLM model")
|
||||
|
|
|
|||
|
|
@ -2,30 +2,46 @@ import os
|
|||
from constants.supported_ollama_models import SUPPORTED_OLLAMA_MODELS
|
||||
from enums.llm_provider import LLMProvider
|
||||
from utils.custom_llm_provider import list_available_custom_models
|
||||
from utils.get_env import get_can_change_keys_env
|
||||
from utils.get_env import (
|
||||
get_can_change_keys_env,
|
||||
get_openai_api_key_env,
|
||||
get_pixabay_api_key_env,
|
||||
get_pexels_api_key_env,
|
||||
)
|
||||
from utils.get_env import get_google_api_key_env
|
||||
from utils.get_env import get_ollama_model_env
|
||||
from utils.get_env import get_custom_llm_api_key_env
|
||||
from utils.get_env import get_custom_llm_url_env
|
||||
from utils.get_env import get_custom_model_env
|
||||
from utils.llm_provider import (
|
||||
get_llm_provider,
|
||||
is_custom_llm_selected,
|
||||
is_ollama_selected,
|
||||
)
|
||||
from utils.ollama import pull_ollama_model
|
||||
from utils.image_provider import (
|
||||
is_pixels_selected,
|
||||
is_pixabay_selected,
|
||||
is_gemini_flash_selected,
|
||||
is_dalle3_selected,
|
||||
)
|
||||
|
||||
|
||||
async def check_llm_model_availability():
|
||||
async def check_llm_and_image_provider_api_or_model_availability():
|
||||
can_change_keys = get_can_change_keys_env() != "false"
|
||||
if not can_change_keys:
|
||||
if get_llm_provider() == LLMProvider.OPENAI:
|
||||
openai_api_key = os.getenv("OPENAI_API_KEY")
|
||||
openai_api_key = get_openai_api_key_env()
|
||||
if not openai_api_key:
|
||||
raise Exception("OPENAI_API_KEY must be provided")
|
||||
|
||||
elif get_llm_provider() == LLMProvider.GOOGLE:
|
||||
google_api_key = os.getenv("GOOGLE_API_KEY")
|
||||
google_api_key = get_google_api_key_env()
|
||||
if not google_api_key:
|
||||
raise Exception("GOOGLE_API_KEY must be provided")
|
||||
|
||||
elif is_ollama_selected():
|
||||
ollama_model = os.getenv("OLLAMA_MODEL")
|
||||
ollama_model = get_ollama_model_env()
|
||||
if not ollama_model:
|
||||
raise Exception("OLLAMA_MODEL must be provided")
|
||||
|
||||
|
|
@ -40,9 +56,9 @@ async def check_llm_model_availability():
|
|||
print("-" * 50)
|
||||
|
||||
elif is_custom_llm_selected():
|
||||
custom_model = os.getenv("CUSTOM_MODEL")
|
||||
custom_llm_url = os.getenv("CUSTOM_LLM_URL")
|
||||
custom_llm_api_key = os.getenv("CUSTOM_LLM_API_KEY")
|
||||
custom_model = get_custom_model_env()
|
||||
custom_llm_url = get_custom_llm_url_env()
|
||||
custom_llm_api_key = get_custom_llm_api_key_env()
|
||||
if not custom_model:
|
||||
raise Exception("CUSTOM_MODEL must be provided")
|
||||
if not custom_llm_url:
|
||||
|
|
@ -58,3 +74,22 @@ async def check_llm_model_availability():
|
|||
print("-" * 50)
|
||||
if custom_model not in models:
|
||||
raise Exception(f"Model {custom_model} is not available")
|
||||
elif is_pixels_selected():
|
||||
pexels_api_key = get_pexels_api_key_env()
|
||||
if not pexels_api_key:
|
||||
raise Exception("PEXELS_API_KEY must be provided")
|
||||
|
||||
elif is_pixabay_selected():
|
||||
pixabay_api_key = get_pixabay_api_key_env()
|
||||
if not pixabay_api_key:
|
||||
raise Exception("PIXABAY_API_KEY must be provided")
|
||||
|
||||
elif is_gemini_flash_selected():
|
||||
google_api_key = get_google_api_key_env()
|
||||
if not google_api_key:
|
||||
raise Exception("GOOGLE_API_KEY must be provided")
|
||||
|
||||
elif is_dalle3_selected():
|
||||
openai_api_key = get_openai_api_key_env()
|
||||
if not openai_api_key:
|
||||
raise Exception("OPENAI_API_KEY must be provided")
|
||||
|
|
|
|||
|
|
@ -43,3 +43,10 @@ def set_custom_model_env(value):
|
|||
|
||||
def set_pexels_api_key_env(value):
|
||||
os.environ["PEXELS_API_KEY"] = value
|
||||
|
||||
def set_image_provider_env(value):
|
||||
os.environ["IMAGE_PROVIDER"] = value
|
||||
|
||||
|
||||
def set_pixabay_api_key_env(value):
|
||||
os.environ["PIXABAY_API_KEY"] = value
|
||||
|
|
@ -13,6 +13,8 @@ from utils.get_env import (
|
|||
get_openai_api_key_env,
|
||||
get_pexels_api_key_env,
|
||||
get_user_config_path_env,
|
||||
get_image_provider_env,
|
||||
get_pixabay_api_key_env
|
||||
)
|
||||
from utils.set_env import (
|
||||
set_custom_llm_api_key_env,
|
||||
|
|
@ -24,6 +26,8 @@ from utils.set_env import (
|
|||
set_ollama_url_env,
|
||||
set_openai_api_key_env,
|
||||
set_pexels_api_key_env,
|
||||
set_image_provider_env,
|
||||
set_pixabay_api_key_env
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -49,6 +53,8 @@ def get_user_config():
|
|||
CUSTOM_LLM_API_KEY=existing_config.CUSTOM_LLM_API_KEY
|
||||
or get_custom_llm_api_key_env(),
|
||||
CUSTOM_MODEL=existing_config.CUSTOM_MODEL or get_custom_model_env(),
|
||||
IMAGE_PROVIDER=existing_config.IMAGE_PROVIDER or get_image_provider_env(),
|
||||
PIXABAY_API_KEY=existing_config.PIXABAY_API_KEY or get_pixabay_api_key_env(),
|
||||
PEXELS_API_KEY=existing_config.PEXELS_API_KEY or get_pexels_api_key_env(),
|
||||
)
|
||||
|
||||
|
|
@ -71,5 +77,9 @@ def update_env_with_user_config():
|
|||
set_custom_llm_api_key_env(user_config.CUSTOM_LLM_API_KEY)
|
||||
if user_config.CUSTOM_MODEL:
|
||||
set_custom_model_env(user_config.CUSTOM_MODEL)
|
||||
if user_config.IMAGE_PROVIDER:
|
||||
set_image_provider_env(user_config.IMAGE_PROVIDER)
|
||||
if user_config.PIXABAY_API_KEY:
|
||||
set_pixabay_api_key_env(user_config.PIXABAY_API_KEY)
|
||||
if user_config.PEXELS_API_KEY:
|
||||
set_pexels_api_key_env(user_config.PEXELS_API_KEY)
|
||||
|
|
|
|||
|
|
@ -1,505 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetTitle,
|
||||
SheetHeader,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Plus, ChevronDown, Trash, BarChart3, PieChart as PieChartIcon, LineChart as LineChartIcon } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { StoreChartData } from '../utils/chartDataTransforms';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { renderChart } from './slide_config';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { RootState } from '@/store/store';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { ChartSettings } from '@/store/slices/presentationGeneration';
|
||||
|
||||
interface ChartEditorProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
chartData: StoreChartData;
|
||||
onChartDataChange: (newData: StoreChartData) => void;
|
||||
chartSettings: ChartSettings;
|
||||
setChartSettings: (newSettings: ChartSettings) => void;
|
||||
}
|
||||
|
||||
const ChartEditor = ({ isOpen, onClose, chartData, onChartDataChange, chartSettings, setChartSettings }: ChartEditorProps) => {
|
||||
const [selectedCell, setSelectedCell] = useState<{ row: number; col: number } | null>(null);
|
||||
const { currentColors } = useSelector((state: RootState) => state.theme);
|
||||
|
||||
const handleCategoryChange = (index: number, value: string) => {
|
||||
const newData = {
|
||||
...chartData,
|
||||
data: {
|
||||
...chartData.data,
|
||||
categories: [
|
||||
...chartData.data.categories.slice(0, index),
|
||||
value,
|
||||
...chartData.data.categories.slice(index + 1)
|
||||
]
|
||||
}
|
||||
};
|
||||
onChartDataChange(newData);
|
||||
};
|
||||
|
||||
|
||||
const handleValueChange = (categoryIndex: number, seriesIndex: number, value: string) => {
|
||||
const newData = {
|
||||
...chartData,
|
||||
data: {
|
||||
...chartData.data,
|
||||
series: chartData.data.series.map((series, idx) => {
|
||||
if (idx === seriesIndex) {
|
||||
return {
|
||||
...series,
|
||||
data: [...series.data.slice(0, categoryIndex), Number(value), ...series.data.slice(categoryIndex + 1)]
|
||||
};
|
||||
}
|
||||
return series;
|
||||
})
|
||||
}
|
||||
};
|
||||
onChartDataChange(newData);
|
||||
};
|
||||
|
||||
const addCategory = () => {
|
||||
|
||||
const newData = {
|
||||
...chartData,
|
||||
data: {
|
||||
...chartData.data,
|
||||
categories: [...chartData.data.categories, ''],
|
||||
series: chartData.data.series.map(series => ({
|
||||
...series,
|
||||
data: [...series.data, 0]
|
||||
}))
|
||||
}
|
||||
};
|
||||
onChartDataChange(newData);
|
||||
};
|
||||
|
||||
const addSeriesBefore = (index: number) => {
|
||||
if (chartData.type === 'pie' && chartData.data.series.length >= 1) {
|
||||
return;
|
||||
} else {
|
||||
if (chartData.data.series.length >= 4) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const newData = {
|
||||
...chartData,
|
||||
data: {
|
||||
...chartData.data,
|
||||
series: [
|
||||
...chartData.data.series.slice(0, index),
|
||||
{
|
||||
name: `Series ${chartData.data.series.length + 1}`,
|
||||
data: new Array(chartData.data.categories.length).fill(0)
|
||||
},
|
||||
...chartData.data.series.slice(index)
|
||||
]
|
||||
}
|
||||
};
|
||||
onChartDataChange(newData);
|
||||
};
|
||||
|
||||
const addSeriesAfter = (index: number) => {
|
||||
if (chartData.type === 'pie' && chartData.data.series.length >= 1) {
|
||||
return;
|
||||
} else {
|
||||
if (chartData.data.series.length >= 4) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const newData = {
|
||||
...chartData,
|
||||
data: {
|
||||
...chartData.data,
|
||||
series: [
|
||||
...chartData.data.series.slice(0, index + 1),
|
||||
{
|
||||
name: `Series ${chartData.data.series.length + 1}`,
|
||||
data: new Array(chartData.data.categories.length).fill(0)
|
||||
},
|
||||
...chartData.data.series.slice(index + 1)
|
||||
]
|
||||
}
|
||||
};
|
||||
onChartDataChange(newData);
|
||||
};
|
||||
|
||||
const removeCategory = (index: number) => {
|
||||
const newData = {
|
||||
...chartData,
|
||||
data: {
|
||||
...chartData.data,
|
||||
categories: chartData.data.categories.filter((_, idx) => idx !== index),
|
||||
series: chartData.data.series.map(series => ({
|
||||
...series,
|
||||
data: series.data.filter((_, idx) => idx !== index)
|
||||
}))
|
||||
}
|
||||
};
|
||||
onChartDataChange(newData);
|
||||
};
|
||||
|
||||
const removeSeries = (index: number) => {
|
||||
const newData = {
|
||||
...chartData,
|
||||
data: {
|
||||
...chartData.data,
|
||||
series: chartData.data.series.filter((_, idx) => idx !== index)
|
||||
}
|
||||
};
|
||||
onChartDataChange(newData);
|
||||
};
|
||||
|
||||
const getColumnLetter = (index: number) => {
|
||||
return String.fromCharCode(65 + index);
|
||||
};
|
||||
|
||||
const isColumnSelected = (colIndex: number) => {
|
||||
return selectedCell?.col === colIndex;
|
||||
};
|
||||
|
||||
const isRowSelected = (rowIndex: number) => {
|
||||
return selectedCell?.row === rowIndex;
|
||||
};
|
||||
|
||||
const isCellSelected = (rowIndex: number, colIndex: number) => {
|
||||
return selectedCell?.row === rowIndex && selectedCell?.col === colIndex;
|
||||
};
|
||||
const disableAddSeries = (chartType: string) => {
|
||||
if (chartType === 'pie') {
|
||||
return chartData.data.series.length >= 1;
|
||||
} else {
|
||||
return chartData.data.series.length >= 4;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={onClose}>
|
||||
<SheetContent side="bottom" className="h-[80vh] overflow-y-auto" onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||
<SheetHeader className='mb-4'>
|
||||
<SheetTitle>Chart Editor</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="grid grid-cols-2 items-start gap-8 h-full">
|
||||
<div className="space-y-4">
|
||||
{/* Spreadsheet Table */}
|
||||
<div className="rounded-md border bg-white">
|
||||
<div className=" overflow-hidden">
|
||||
<table className="w-full border-collapse ">
|
||||
<thead className='w-full'>
|
||||
<tr>
|
||||
<th className={`w-12 border-b border-r p-2 sticky top-0 z-10 transition-colors duration-200
|
||||
${selectedCell ? 'bg-[#f3f3f3]' : 'bg-[#f8f9fa]'}`}>
|
||||
</th>
|
||||
{/* First column for categories */}
|
||||
<th className={`border-b border-r p-2 sticky top-0 z-10 transition-colors duration-200
|
||||
${isColumnSelected(0) ? 'bg-[#e8f0fe]' : 'bg-[#f8f9fa]'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[13px] text-gray-600">A</span>
|
||||
</div>
|
||||
</th>
|
||||
{/* Data columns for each series */}
|
||||
{chartData && chartData.data.series && chartData.data.series.map((_, index) => (
|
||||
<th key={index}
|
||||
className={`border-b border-r p-2 sticky top-0 z-10 transition-colors duration-200
|
||||
${isColumnSelected(index + 1) ? 'bg-[#e8f0fe]' : 'bg-[#f8f9fa]'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[13px] text-gray-600">
|
||||
{getColumnLetter(index + 1)}
|
||||
</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[200px] space-y-2">
|
||||
<DropdownMenuItem className='cursor-pointer hover:bg-gray-100' onClick={() => addSeriesBefore(index)} disabled={disableAddSeries(chartData.type)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Column before
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className='cursor-pointer hover:bg-gray-100' onClick={() => addSeriesAfter(index)} disabled={disableAddSeries(chartData.type)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Column after
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className='cursor-pointer hover:bg-gray-100' onClick={() => removeSeries(index)}>
|
||||
<Trash className="mr-2 h-4 w-4 text-red-500" />
|
||||
Delete Column
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
<th className="w-10 bg-[#f8f9fa] border-b p-2 sticky top-0 z-10">
|
||||
<Button
|
||||
onClick={() => addSeriesAfter(chartData.data.series.length - 1)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
disabled={disableAddSeries(chartData.type)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</th>
|
||||
</tr>
|
||||
{/* New row for series names */}
|
||||
<tr>
|
||||
<td className="border-r p-2 bg-[#f8f9fa]"></td>
|
||||
<td className="border-r p-2 bg-[#f8f9fa]"></td>
|
||||
{chartData.data.series.map((series, index) => (
|
||||
<td key={index} className="border p-1 bg-[#f8f9fa]">
|
||||
<Input
|
||||
value={series.name}
|
||||
onChange={(e) => {
|
||||
const newSeries = chartData.data.series.map((s, i) =>
|
||||
i === index ? { ...s, name: e.target.value } : s
|
||||
);
|
||||
onChartDataChange({
|
||||
...chartData,
|
||||
data: {
|
||||
...chartData.data,
|
||||
series: newSeries
|
||||
}
|
||||
});
|
||||
}}
|
||||
className="border-0 focus-visible:ring-0 focus:ring-0 h-7 text-[13px] bg-transparent"
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td className="w-10 bg-[#f8f9fa]"></td>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody className='block h-full max-h-[500px] custom_scrollbar overflow-y-auto'>
|
||||
{chartData.data.categories.map((category, rowIndex) => (
|
||||
<tr key={rowIndex} className="group">
|
||||
{/* Row Numbers */}
|
||||
<td className={`border-r p-2 text-[13px] text-gray-600 w-12 text-center transition-colors duration-200
|
||||
${isRowSelected(rowIndex) ? 'bg-[#e8f0fe]' : 'bg-[#f8f9fa]'}`}>
|
||||
{rowIndex + 1}
|
||||
</td>
|
||||
|
||||
{/* Category Cell */}
|
||||
<td
|
||||
className={`border p-1 relative transition-all duration-200
|
||||
${isCellSelected(rowIndex, 0)
|
||||
? 'bg-[#e8f0fe] outline outline-2 outline-blue-500 z-10'
|
||||
: 'hover:bg-[#f1f3f4]'}`}
|
||||
onClick={() => setSelectedCell({ row: rowIndex, col: 0 })}
|
||||
>
|
||||
<Input
|
||||
value={category}
|
||||
onChange={(e) => handleCategoryChange(rowIndex, e.target.value)}
|
||||
className="border-0 focus-visible:ring-0 focus:ring-0 h-7 text-[13px] bg-transparent"
|
||||
/>
|
||||
</td>
|
||||
|
||||
|
||||
{/* Series Data Cells */}
|
||||
{/* series name */}
|
||||
{chartData.data.series.map((series, seriesIndex) => (
|
||||
<td
|
||||
key={seriesIndex}
|
||||
className={`border p-1 relative transition-all duration-200
|
||||
${isCellSelected(rowIndex, seriesIndex + 1)
|
||||
? 'bg-[#e8f0fe] outline outline-2 outline-blue-500 z-10'
|
||||
: 'hover:bg-[#f1f3f4]'}`}
|
||||
onClick={() => setSelectedCell({ row: rowIndex, col: seriesIndex + 1 })}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
value={series.data[rowIndex]}
|
||||
onChange={(e) => handleValueChange(rowIndex, seriesIndex, e.target.value)}
|
||||
className="border-0 focus-visible:ring-0 focus:ring-0 h-7 text-[13px] bg-transparent text-right"
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
|
||||
<td className="w-10 p-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeCategory(rowIndex)}
|
||||
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 text-red-500 hover:text-red-600 hover:bg-red-50"
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Add Row Button */}
|
||||
<div className="p-2 border-t">
|
||||
<Button
|
||||
onClick={addCategory}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full h-7 text-[13px] hover:bg-[#f8f9fa]"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add row
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add the chart preview section */}
|
||||
<div className="border rounded-lg p-4 bg-white">
|
||||
<h3 className="text-lg font-semibold mb-4">Preview</h3>
|
||||
<div className="w-full" style={{ backgroundColor: currentColors.slideBg }}>
|
||||
{renderChart(chartData, false, currentColors, chartSettings)}
|
||||
</div>
|
||||
|
||||
{/* Add chart type selection */}
|
||||
<div className="mt-4 border-t pt-4 custom_scrollbar">
|
||||
<h4 className="text-sm font-medium mb-2">Chart Type</h4>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={chartData.type === 'bar' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newData = { ...chartData, type: 'bar' as 'bar' };
|
||||
onChartDataChange(newData);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
Bar
|
||||
</Button>
|
||||
<Button
|
||||
variant={chartData.type === 'line' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newData = { ...chartData, type: 'line' as 'line' };
|
||||
onChartDataChange(newData);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<LineChartIcon className="h-4 w-4" />
|
||||
Line
|
||||
</Button>
|
||||
<Button
|
||||
variant={chartData.type === 'pie' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newData = { ...chartData, type: 'pie' as 'pie' };
|
||||
onChartDataChange(newData);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<PieChartIcon className="h-4 w-4" />
|
||||
Pie
|
||||
</Button>
|
||||
</div>
|
||||
<div className="border-t mt-6 pt-4 mb-6 flex flex-col items-start gap-4">
|
||||
{chartData.type !== 'line' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex w-[350px] items-center justify-between p-3 bg-gray-100 rounded-lg">
|
||||
<Label htmlFor="data-label" className="font-medium">Data Label</Label>
|
||||
<Switch
|
||||
id="data-label"
|
||||
checked={chartSettings.showDataLabel}
|
||||
onCheckedChange={(checked) => setChartSettings({ ...chartSettings, showDataLabel: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{chartSettings.showDataLabel && (
|
||||
<div className="space-y-4 p-4 max-w-[350px] bg-gray-50 rounded-lg">
|
||||
<Label className="font-medium block mb-2">Data Label Position</Label>
|
||||
<Tabs className="w-full" defaultValue={chartSettings.dataLabel.dataLabelPosition.toLowerCase()}>
|
||||
<TabsList className="w-full grid grid-cols-2 mb-4">
|
||||
<TabsTrigger onClick={() => setChartSettings({
|
||||
...chartSettings, dataLabel: {
|
||||
...chartSettings.dataLabel,
|
||||
dataLabelPosition: 'Inside'
|
||||
}
|
||||
})} value="inside">Inside</TabsTrigger>
|
||||
<TabsTrigger onClick={() => setChartSettings({
|
||||
...chartSettings, dataLabel: {
|
||||
...chartSettings.dataLabel,
|
||||
dataLabelPosition: 'Outside'
|
||||
}
|
||||
})} value="outside">Outside</TabsTrigger>
|
||||
</TabsList>
|
||||
{chartData.type === 'bar' && <TabsContent value="inside">
|
||||
<Label className="font-medium block mb-2">Data Label Alignment</Label>
|
||||
<Tabs className="w-full" defaultValue={chartSettings.dataLabel.dataLabelAlignment.toLowerCase()}>
|
||||
<TabsList className="w-full grid grid-cols-3">
|
||||
<TabsTrigger onClick={() => setChartSettings({
|
||||
...chartSettings, dataLabel: {
|
||||
...chartSettings.dataLabel,
|
||||
dataLabelAlignment: 'Base'
|
||||
}
|
||||
})} value="base">Base</TabsTrigger>
|
||||
<TabsTrigger onClick={() => setChartSettings({
|
||||
...chartSettings, dataLabel: {
|
||||
...chartSettings.dataLabel,
|
||||
dataLabelAlignment: 'Center'
|
||||
}
|
||||
})} value="center">Center</TabsTrigger>
|
||||
<TabsTrigger onClick={() => setChartSettings({
|
||||
...chartSettings, dataLabel: {
|
||||
...chartSettings.dataLabel,
|
||||
dataLabelAlignment: 'End'
|
||||
}
|
||||
})} value="end">End</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</TabsContent>}
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex w-[350px] items-center justify-between p-3 bg-gray-100 rounded-lg">
|
||||
<Label htmlFor="legend" className="font-medium">Legend</Label>
|
||||
<Switch
|
||||
id="legend"
|
||||
checked={chartSettings.showLegend}
|
||||
onCheckedChange={(checked) => setChartSettings({ ...chartSettings, showLegend: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{chartData.type !== 'pie' && <div className="flex w-[350px] items-center justify-between p-3 bg-gray-100 rounded-lg">
|
||||
<Label htmlFor="grid" className="font-medium">Grid Lines</Label>
|
||||
<Switch
|
||||
id="grid"
|
||||
checked={chartSettings.showGrid}
|
||||
onCheckedChange={(checked) => setChartSettings({ ...chartSettings, showGrid: checked })}
|
||||
/>
|
||||
</div>}
|
||||
|
||||
{chartData.type !== 'pie' && <div className="flex w-[350px] items-center justify-between p-3 bg-gray-100 rounded-lg">
|
||||
<Label htmlFor="axis-labels" className="font-medium">Axis Labels</Label>
|
||||
<Switch
|
||||
id="axis-labels"
|
||||
checked={chartSettings.showAxisLabel}
|
||||
onCheckedChange={(checked) => setChartSettings({ ...chartSettings, showAxisLabel: checked })}
|
||||
/>
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChartEditor;
|
||||
|
|
@ -0,0 +1,321 @@
|
|||
"use client";
|
||||
|
||||
import React, { ReactNode, useRef, useEffect, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { updateSlideImage, updateSlideIcon } from '@/store/slices/presentationGeneration';
|
||||
import ImageEditor from './ImageEditor';
|
||||
import IconsEditor from './IconsEditor';
|
||||
|
||||
interface EditableLayoutWrapperProps {
|
||||
children: ReactNode;
|
||||
slideIndex: number;
|
||||
slideData: any;
|
||||
isEditMode?: boolean;
|
||||
}
|
||||
|
||||
interface EditableElement {
|
||||
id: string;
|
||||
type: 'image' | 'icon';
|
||||
src: string;
|
||||
dataPath: string;
|
||||
data: any;
|
||||
element: HTMLImageElement;
|
||||
}
|
||||
|
||||
const EditableLayoutWrapper: React.FC<EditableLayoutWrapperProps> = ({
|
||||
children,
|
||||
slideIndex,
|
||||
slideData,
|
||||
isEditMode = true,
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [editableElements, setEditableElements] = useState<EditableElement[]>([]);
|
||||
const [activeEditor, setActiveEditor] = useState<EditableElement | null>(null);
|
||||
|
||||
/**
|
||||
* Recursively searches for image/icon data in the slide data structure
|
||||
*/
|
||||
const findDataPath = (targetUrl: string, data: any, path: string = ''): { path: string; type: 'image' | 'icon'; data: any } | null => {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
|
||||
// Check current level for __image_url__ or __icon_url__
|
||||
if (data.__image_url__ && isMatchingUrl(data.__image_url__, targetUrl)) {
|
||||
return { path, type: 'image', data };
|
||||
}
|
||||
|
||||
if (data.__icon_url__ && isMatchingUrl(data.__icon_url__, targetUrl)) {
|
||||
return { path, type: 'icon', data };
|
||||
}
|
||||
|
||||
// Recursively check nested objects and arrays
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
const newPath = path ? `${path}.${key}` : key;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const result = findDataPath(targetUrl, value[i], `${newPath}[${i}]`);
|
||||
if (result) return result;
|
||||
}
|
||||
} else if (value && typeof value === 'object') {
|
||||
const result = findDataPath(targetUrl, value, newPath);
|
||||
if (result) return result;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if two URLs match using various comparison strategies
|
||||
*/
|
||||
const isMatchingUrl = (url1: string, url2: string): boolean => {
|
||||
if (!url1 || !url2) return false;
|
||||
|
||||
// Direct match
|
||||
if (url1 === url2) return true;
|
||||
|
||||
// Remove protocol and domain differences
|
||||
const cleanUrl1 = url1.replace(/^https?:\/\/[^\/]+/, '').replace(/^\/+/, '');
|
||||
const cleanUrl2 = url2.replace(/^https?:\/\/[^\/]+/, '').replace(/^\/+/, '');
|
||||
|
||||
if (cleanUrl1 === cleanUrl2) return true;
|
||||
|
||||
// Handle app_data paths and placeholder URLs
|
||||
if (url1.includes('/app_data/') || url2.includes('/app_data/') ||
|
||||
url1.includes('placeholder') || url2.includes('placeholder')) {
|
||||
const getFilename = (path: string) => path.split('/').pop() || '';
|
||||
const filename1 = getFilename(url1);
|
||||
const filename2 = getFilename(url2);
|
||||
if (filename1 === filename2 && filename1 !== '') return true;
|
||||
}
|
||||
|
||||
// Extract and compare filenames for other URLs
|
||||
const getFilename = (path: string) => path.split('/').pop() || '';
|
||||
const filename1 = getFilename(url1);
|
||||
const filename2 = getFilename(url2);
|
||||
|
||||
if (filename1 === filename2 && filename1 !== '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if one URL is contained in another (for partial matches)
|
||||
if (url1.includes(url2) || url2.includes(url1)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Finds and processes images in the DOM, making them editable
|
||||
*/
|
||||
const findAndProcessImages = () => {
|
||||
if (!containerRef.current || !isEditMode) return;
|
||||
|
||||
const imgElements = containerRef.current.querySelectorAll('img:not([data-editable-processed])');
|
||||
const newEditableElements: EditableElement[] = [];
|
||||
|
||||
imgElements.forEach((img, index) => {
|
||||
const htmlImg = img as HTMLImageElement;
|
||||
const src = htmlImg.src;
|
||||
|
||||
if (src) {
|
||||
const result = findDataPath(src, slideData);
|
||||
|
||||
if (result) {
|
||||
const { path: dataPath, type, data } = result;
|
||||
|
||||
// Mark as processed to prevent re-processing
|
||||
htmlImg.setAttribute('data-editable-processed', 'true');
|
||||
|
||||
const editableElement: EditableElement = {
|
||||
id: `${type}-${dataPath}-${index}`,
|
||||
type,
|
||||
src,
|
||||
dataPath,
|
||||
data,
|
||||
element: htmlImg
|
||||
};
|
||||
|
||||
newEditableElements.push(editableElement);
|
||||
|
||||
// Add click handler directly to the image
|
||||
const clickHandler = (e: Event) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setActiveEditor(editableElement);
|
||||
};
|
||||
|
||||
htmlImg.addEventListener('click', clickHandler);
|
||||
|
||||
// Add hover effects without changing layout
|
||||
htmlImg.style.cursor = 'pointer';
|
||||
htmlImg.style.transition = 'filter 0.2s, transform 0.2s';
|
||||
|
||||
const mouseEnterHandler = () => {
|
||||
htmlImg.style.filter = 'brightness(0.9)';
|
||||
|
||||
};
|
||||
|
||||
const mouseLeaveHandler = () => {
|
||||
htmlImg.style.filter = 'brightness(1)';
|
||||
|
||||
};
|
||||
|
||||
htmlImg.addEventListener('mouseenter', mouseEnterHandler);
|
||||
htmlImg.addEventListener('mouseleave', mouseLeaveHandler);
|
||||
|
||||
// Store cleanup functions
|
||||
(htmlImg as any)._editableCleanup = () => {
|
||||
htmlImg.removeEventListener('click', clickHandler);
|
||||
htmlImg.removeEventListener('mouseenter', mouseEnterHandler);
|
||||
htmlImg.removeEventListener('mouseleave', mouseLeaveHandler);
|
||||
htmlImg.style.cursor = '';
|
||||
htmlImg.style.transition = '';
|
||||
htmlImg.style.filter = '';
|
||||
htmlImg.style.transform = '';
|
||||
htmlImg.removeAttribute('data-editable-processed');
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setEditableElements(prev => [...prev, ...newEditableElements]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Cleanup function to remove event listeners and reset styles
|
||||
*/
|
||||
const cleanupElements = () => {
|
||||
editableElements.forEach(({ element }) => {
|
||||
if ((element as any)._editableCleanup) {
|
||||
(element as any)._editableCleanup();
|
||||
}
|
||||
});
|
||||
setEditableElements([]);
|
||||
};
|
||||
|
||||
// Wait for LoadableComponent to render and then process images
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
findAndProcessImages();
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
cleanupElements();
|
||||
};
|
||||
}, [slideData, children]);
|
||||
|
||||
// Re-run when container content changes
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
const hasNewImages = mutations.some(mutation =>
|
||||
Array.from(mutation.addedNodes).some(node =>
|
||||
node.nodeType === Node.ELEMENT_NODE &&
|
||||
(
|
||||
(node as Element).tagName === 'IMG' ||
|
||||
(node as Element).querySelector('img:not([data-editable-processed])')
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
if (hasNewImages) {
|
||||
setTimeout(findAndProcessImages, 100);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(containerRef.current, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [slideData]);
|
||||
|
||||
/**
|
||||
* Handles closing the active editor
|
||||
*/
|
||||
const handleEditorClose = () => {
|
||||
setActiveEditor(null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles image change from ImageEditor
|
||||
*/
|
||||
const handleImageChange = (newImageUrl: string, prompt?: string) => {
|
||||
if (activeEditor && activeEditor.element) {
|
||||
// Update the DOM element immediately for visual feedback
|
||||
activeEditor.element.src = newImageUrl;
|
||||
|
||||
// Update Redux store
|
||||
dispatch(updateSlideImage({
|
||||
slideIndex,
|
||||
dataPath: activeEditor.dataPath,
|
||||
imageUrl: newImageUrl,
|
||||
prompt: prompt || activeEditor.data?.__image_prompt__ || ''
|
||||
}));
|
||||
|
||||
setActiveEditor(null);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles icon change from IconsEditor
|
||||
*/
|
||||
const handleIconChange = (newIconUrl: string, query?: string) => {
|
||||
if (activeEditor && activeEditor.element) {
|
||||
// Update the DOM element immediately for visual feedback
|
||||
activeEditor.element.src = newIconUrl;
|
||||
|
||||
// Update Redux store
|
||||
dispatch(updateSlideIcon({
|
||||
slideIndex,
|
||||
dataPath: activeEditor.dataPath,
|
||||
iconUrl: newIconUrl,
|
||||
query: query || activeEditor.data?.__icon_query__ || ''
|
||||
}));
|
||||
|
||||
setActiveEditor(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="editable-layout-wrapper">
|
||||
{children}
|
||||
|
||||
{/* Render ImageEditor when an image is being edited */}
|
||||
{activeEditor && activeEditor.type === 'image' && (
|
||||
<ImageEditor
|
||||
initialImage={activeEditor.src}
|
||||
slideIndex={slideIndex}
|
||||
promptContent={activeEditor.data?.__image_prompt__ || ''}
|
||||
imageIdx={0}
|
||||
properties={null}
|
||||
onClose={handleEditorClose}
|
||||
onImageChange={handleImageChange}
|
||||
>
|
||||
<div />
|
||||
</ImageEditor>
|
||||
)}
|
||||
|
||||
{/* Render IconsEditor when an icon is being edited */}
|
||||
{activeEditor && activeEditor.type === 'icon' && (
|
||||
<IconsEditor
|
||||
icon={activeEditor.src}
|
||||
index={0}
|
||||
icon_prompt={activeEditor.data?.__icon_query__ ? [activeEditor.data.__icon_query__] : []}
|
||||
onClose={handleEditorClose}
|
||||
onIconChange={handleIconChange}
|
||||
>
|
||||
<div />
|
||||
</IconsEditor>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditableLayoutWrapper;
|
||||
|
|
@ -7,66 +7,52 @@ import {
|
|||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { PlusIcon, Search } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { PresentationGenerationApi } from "../services/api/presentation-generation";
|
||||
import { RootState } from "@/store/store";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { Search } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { updateSlideIcon } from "@/store/slices/presentationGeneration";
|
||||
import { PresentationGenerationApi } from "../services/api/presentation-generation";
|
||||
import { getStaticFileUrl } from "../utils/others";
|
||||
|
||||
interface IconsEditorProps {
|
||||
icon: string;
|
||||
index: number;
|
||||
backgroundColor: string;
|
||||
hasBg: boolean;
|
||||
slideIndex: number;
|
||||
elementId: string;
|
||||
isWhite?: boolean;
|
||||
className?: string;
|
||||
icon_prompt?: string[] | null;
|
||||
onClose?: () => void;
|
||||
onIconChange?: (newIconUrl: string, query?: string) => void;
|
||||
}
|
||||
|
||||
const IconsEditor = ({
|
||||
icon: initialIcon,
|
||||
index,
|
||||
backgroundColor,
|
||||
hasBg,
|
||||
className,
|
||||
slideIndex,
|
||||
elementId,
|
||||
icon_prompt,
|
||||
onClose,
|
||||
}: IconsEditorProps) => {
|
||||
const dispatch = useDispatch();
|
||||
onIconChange,
|
||||
|
||||
}: IconsEditorProps) => {
|
||||
// State management
|
||||
const [icon, setIcon] = useState(initialIcon);
|
||||
const [icons, setIcons] = useState<string[]>([]);
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState<string>(
|
||||
icon_prompt?.[0] || ""
|
||||
);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Update local state when initial icon changes
|
||||
useEffect(() => {
|
||||
setIcon(initialIcon);
|
||||
}, [initialIcon]);
|
||||
|
||||
// Search for icons when component opens
|
||||
useEffect(() => {
|
||||
if (isEditorOpen) {
|
||||
handleIconSearch();
|
||||
}
|
||||
}, [isEditorOpen]);
|
||||
|
||||
const handleIconClick = () => {
|
||||
setIsEditorOpen(true);
|
||||
};
|
||||
handleIconSearch();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Searches for icons based on the current query
|
||||
*/
|
||||
const handleIconSearch = async () => {
|
||||
setLoading(true);
|
||||
const presentation_id = searchParams.get("id");
|
||||
|
|
@ -88,94 +74,100 @@ const IconsEditor = ({
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles icon selection and calls the parent callback
|
||||
*/
|
||||
const handleIconChange = (newIcon: string) => {
|
||||
|
||||
|
||||
setIcon(newIcon);
|
||||
dispatch(
|
||||
updateSlideIcon({ index: slideIndex, iconIdx: index, icon: newIcon })
|
||||
);
|
||||
setIsEditorOpen(false);
|
||||
|
||||
if (onIconChange) {
|
||||
onIconChange(newIcon, searchQuery || icon_prompt?.[0] || '');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={true} onOpenChange={() => onClose?.()}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-[400px]"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Choose Icon</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="mt-6 space-y-4">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleIconSearch();
|
||||
}}
|
||||
>
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 w-4 h-4" />
|
||||
<div className="icons-editor-container">
|
||||
|
||||
<Input
|
||||
placeholder="Search icons..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="w-full text-semibold text-[#51459e]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
|
||||
<Sheet open={true} onOpenChange={() => onClose?.()}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-[400px]"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Choose Icon</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
{/* Search Form */}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleIconSearch();
|
||||
}}
|
||||
>
|
||||
Search
|
||||
</Button>
|
||||
</form>
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 w-4 h-4" />
|
||||
<Input
|
||||
placeholder="Search icons..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="w-full text-semibold text-[#51459e]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Search
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Icons grid */}
|
||||
<div className="max-h-[80vh] hide-scrollbar overflow-y-auto p-1">
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{Array.from({ length: 40 }).map((_, index) => (
|
||||
<Skeleton key={index} className="w-16 h-16 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : icons.length > 0 ? (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{icons.map((iconSrc, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleIconChange(iconSrc);
|
||||
}}
|
||||
className="w-12 h-12 cursor-pointer group relative rounded-lg overflow-hidden hover:bg-gray-100 p-2"
|
||||
>
|
||||
<img
|
||||
src={getStaticFileUrl(iconSrc)}
|
||||
alt={`Icon ${idx + 1}`}
|
||||
className="w-full h-full object-contain "
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center w-full h-[60vh] text-center text-gray-500 space-y-4">
|
||||
<Search className="w-12 h-12 text-gray-400" />
|
||||
<p className="text-sm">No icons found for your search.</p>
|
||||
<p className="text-xs">Try refining your search query.</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Icons Grid */}
|
||||
<div className="max-h-[80vh] hide-scrollbar overflow-y-auto p-1">
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{Array.from({ length: 40 }).map((_, index) => (
|
||||
<Skeleton key={index} className="w-16 h-16 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : icons.length > 0 ? (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{icons.map((iconSrc, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleIconChange(iconSrc);
|
||||
}}
|
||||
className="w-12 h-12 cursor-pointer group relative rounded-lg overflow-hidden hover:bg-gray-100 p-2 transition-colors"
|
||||
>
|
||||
<img
|
||||
src={getStaticFileUrl(iconSrc)}
|
||||
alt={`Icon ${idx + 1}`}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center w-full h-[60vh] text-center text-gray-500 space-y-4">
|
||||
<Search className="w-12 h-12 text-gray-400" />
|
||||
<p className="text-sm">No icons found for your search.</p>
|
||||
<p className="text-xs">Try refining your search query.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -13,32 +13,25 @@ import {
|
|||
Wand2,
|
||||
Upload,
|
||||
Move,
|
||||
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useSelector } from "react-redux";
|
||||
import { PresentationGenerationApi } from "../services/api/presentation-generation";
|
||||
import { RootState } from "@/store/store";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
updateSlideImage,
|
||||
updateSlideProperties,
|
||||
} from "@/store/slices/presentationGeneration";
|
||||
import { getStaticFileUrl, ThemeImagePrompt } from "../utils/others";
|
||||
|
||||
|
||||
import { ThemeImagePrompt } from "../utils/others";
|
||||
|
||||
interface ImageEditorProps {
|
||||
initialImage: string | null;
|
||||
imageIdx?: number;
|
||||
|
||||
slideIndex: number;
|
||||
|
||||
className?: string;
|
||||
promptContent?: string;
|
||||
properties?: null | any;
|
||||
onClose?: () => void;
|
||||
onImageChange?: (newImageUrl: string, prompt?: string) => void;
|
||||
|
||||
}
|
||||
|
||||
const ImageEditor = ({
|
||||
|
|
@ -48,12 +41,13 @@ const ImageEditor = ({
|
|||
promptContent,
|
||||
properties,
|
||||
onClose,
|
||||
onImageChange,
|
||||
|
||||
}: ImageEditorProps) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { currentTheme } = useSelector((state: RootState) => state.theme);
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// State management
|
||||
const [image, setImage] = useState(initialImage);
|
||||
const [previewImages, setPreviewImages] = useState([initialImage]);
|
||||
const [prompt, setPrompt] = useState<string>("");
|
||||
|
|
@ -62,6 +56,8 @@ const ImageEditor = ({
|
|||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [uploadedImageUrl, setUploadedImageUrl] = useState<string | null>(null);
|
||||
|
||||
// Focus point and object fit for image editing
|
||||
const [isFocusPointMode, setIsFocusPointMode] = useState(false);
|
||||
const [focusPoint, setFocusPoint] = useState(
|
||||
(properties &&
|
||||
|
|
@ -77,11 +73,14 @@ const ImageEditor = ({
|
|||
properties[imageIdx].initialObjectFit) ||
|
||||
"cover"
|
||||
);
|
||||
|
||||
// Refs
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const imageContainerRef = useRef<HTMLDivElement>(null);
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
const popoverContentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Update local state when initial image changes
|
||||
useEffect(() => {
|
||||
setImage(initialImage);
|
||||
setPreviewImages([initialImage]);
|
||||
|
|
@ -97,9 +96,7 @@ const ImageEditor = ({
|
|||
!toolbarRef.current.contains(event.target as Node) &&
|
||||
!popoverContentRef.current
|
||||
) {
|
||||
|
||||
if (isFocusPointMode) {
|
||||
// saveFocusPoint(); // Save focus point before closing
|
||||
saveImageProperties(objectFit, focusPoint);
|
||||
}
|
||||
setIsFocusPointMode(false);
|
||||
|
|
@ -110,21 +107,22 @@ const ImageEditor = ({
|
|||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [isFocusPointMode, focusPoint]);
|
||||
|
||||
|
||||
}, [isFocusPointMode, focusPoint, objectFit]);
|
||||
|
||||
/**
|
||||
* Handles image selection and calls the parent callback
|
||||
*/
|
||||
const handleImageChange = (newImage: string) => {
|
||||
setImage(newImage);
|
||||
dispatch(
|
||||
updateSlideImage({
|
||||
index: slideIndex,
|
||||
imageIdx: imageIdx,
|
||||
image: newImage,
|
||||
})
|
||||
);
|
||||
|
||||
if (onImageChange) {
|
||||
onImageChange(newImage, promptContent);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles focus point adjustment when clicking on the image
|
||||
*/
|
||||
const handleFocusPointClick = (e: React.MouseEvent) => {
|
||||
if (!isFocusPointMode || !imageRef.current) return;
|
||||
|
||||
|
|
@ -147,14 +145,19 @@ const ImageEditor = ({
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggles focus point adjustment mode
|
||||
*/
|
||||
const toggleFocusPointMode = () => {
|
||||
if (isFocusPointMode) {
|
||||
// If turning off focus point mode, save the current focus point
|
||||
// saveFocusPoint();
|
||||
saveImageProperties(objectFit, focusPoint);
|
||||
}
|
||||
setIsFocusPointMode(!isFocusPointMode);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles object fit change
|
||||
*/
|
||||
const handleFitChange = (fit: "cover" | "contain" | "fill") => {
|
||||
setObjectFit(fit);
|
||||
|
||||
|
|
@ -162,10 +165,12 @@ const ImageEditor = ({
|
|||
imageRef.current.style.objectFit = fit;
|
||||
}
|
||||
|
||||
// Save the fit change to your state
|
||||
saveImageProperties(fit, focusPoint);
|
||||
};
|
||||
|
||||
/**
|
||||
* Saves image properties (focus point and object fit)
|
||||
*/
|
||||
const saveImageProperties = (
|
||||
fit: "cover" | "contain" | "fill",
|
||||
focusPoint: { x: number; y: number }
|
||||
|
|
@ -174,16 +179,12 @@ const ImageEditor = ({
|
|||
initialObjectFit: fit,
|
||||
initialFocusPoint: focusPoint,
|
||||
};
|
||||
|
||||
dispatch(
|
||||
updateSlideProperties({
|
||||
index: slideIndex,
|
||||
itemIdx: imageIdx,
|
||||
properties: propertiesData,
|
||||
})
|
||||
);
|
||||
// TODO: Save to Redux store if needed
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates new images using AI
|
||||
*/
|
||||
const handleGenerateImage = async () => {
|
||||
try {
|
||||
setIsGenerating(true);
|
||||
|
|
@ -208,26 +209,24 @@ const ImageEditor = ({
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles file upload
|
||||
*/
|
||||
const handleFileUpload = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
const presentation_id = searchParams.get("id");
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Check file size (e.g., 5MB limit)
|
||||
// Validate file size (5MB limit)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
const error_message = "File size should be less than 5MB";
|
||||
|
||||
setUploadError(error_message);
|
||||
setUploadError("File size should be less than 5MB");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check file type
|
||||
// Validate file type
|
||||
if (!file.type.startsWith("image/")) {
|
||||
const error_message = "Please upload an image file";
|
||||
|
||||
setUploadError(error_message);
|
||||
setUploadError("Please upload an image file");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -249,356 +248,191 @@ const ImageEditor = ({
|
|||
throw new Error(result.error || 'Upload failed');
|
||||
}
|
||||
|
||||
// Update state with the returned path
|
||||
setUploadedImageUrl(result.filePath);
|
||||
} catch (err) {
|
||||
const error_message = "Failed to upload image. Please try again.";
|
||||
|
||||
setUploadError(error_message);
|
||||
setUploadError("Failed to upload image. Please try again.");
|
||||
console.error("Upload error:", err);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Sheet open={true} onOpenChange={() => onClose?.()}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-[600px]"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Update Image</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="image-editor-container">
|
||||
|
||||
<div className="mt-6">
|
||||
<Tabs defaultValue="edit" className="w-full">
|
||||
<TabsList className="grid bg-blue-100 border border-blue-300 w-full grid-cols-3 mx-auto ">
|
||||
<TabsTrigger className="font-medium" value="edit">
|
||||
Edit
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className="font-medium" value="generate">
|
||||
AI Generate
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className="font-medium" value="upload">
|
||||
Upload
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="edit" className="mt-4 space-y-4">
|
||||
<div className="space-y-4">
|
||||
{/* Current Image Preview */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-base font-medium">Current Image</h3>
|
||||
<div
|
||||
ref={imageContainerRef}
|
||||
className="relative aspect-[4/3] w-full overflow-hidden rounded-lg border bg-gray-100"
|
||||
>
|
||||
{image ? (
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={image}
|
||||
alt="Current image"
|
||||
className="w-full h-full object-cover cursor-pointer"
|
||||
style={{
|
||||
objectFit: objectFit,
|
||||
objectPosition: `${focusPoint.x}% ${focusPoint.y}%`,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleFocusPointClick(e);
|
||||
}}
|
||||
onError={(e) => {
|
||||
console.error('Image failed to load:', image);
|
||||
e.currentTarget.src = '/placeholder-image.png';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400">
|
||||
<div className="text-center">
|
||||
<Upload className="w-8 h-8 mx-auto mb-2" />
|
||||
<p className="text-sm">No image selected</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Sheet open={true} onOpenChange={() => onClose?.()}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-[600px]"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Update Image</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
{/* Focus Point Indicator */}
|
||||
{isFocusPointMode && image && (
|
||||
<div
|
||||
className="absolute w-4 h-4 bg-blue-500 border-2 border-white rounded-full transform -translate-x-1/2 -translate-y-1/2 pointer-events-none shadow-lg"
|
||||
style={{
|
||||
left: `${focusPoint.x}%`,
|
||||
top: `${focusPoint.y}%`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Debug info */}
|
||||
{image && (
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
<p><strong>Image Path:</strong> {image}</p>
|
||||
<p><strong>Resolved URL:</strong> {image}</p>
|
||||
<p><strong>Focus Point:</strong> {focusPoint.x.toFixed(1)}%, {focusPoint.y.toFixed(1)}%</p>
|
||||
<p><strong>Object Fit:</strong> {objectFit}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Editing Controls */}
|
||||
<div className="mt-6">
|
||||
<Tabs defaultValue="generate" className="w-full">
|
||||
<TabsList className="grid bg-blue-100 border border-blue-300 w-full grid-cols-2 mx-auto">
|
||||
<TabsTrigger className="font-medium" value="generate">
|
||||
AI Generate
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className="font-medium" value="upload">
|
||||
Upload
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
{/* Generate Tab */}
|
||||
<TabsContent value="generate" className="mt-4 space-y-4">
|
||||
<div className="space-y-4">
|
||||
{/* Focus Point Controls */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium">Focus Point</h4>
|
||||
<Button
|
||||
variant={isFocusPointMode ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleFocusPointMode();
|
||||
}}
|
||||
disabled={!image}
|
||||
>
|
||||
<Move className="w-4 h-4 mr-2" />
|
||||
{isFocusPointMode ? "Done" : "Adjust"}
|
||||
</Button>
|
||||
</div>
|
||||
{isFocusPointMode && (
|
||||
<p className="text-xs text-gray-500">
|
||||
Click on the image above to set the focus point
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-1">Current Prompt</h3>
|
||||
<p className="text-sm text-gray-500">{promptContent}</p>
|
||||
</div>
|
||||
|
||||
{/* Object Fit Controls */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">Image Fit</h4>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={objectFit === "cover" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleFitChange("cover");
|
||||
}}
|
||||
className="flex-1"
|
||||
disabled={!image}
|
||||
>
|
||||
Cover
|
||||
</Button>
|
||||
<Button
|
||||
variant={objectFit === "contain" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleFitChange("contain");
|
||||
}}
|
||||
className="flex-1"
|
||||
disabled={!image}
|
||||
>
|
||||
Contain
|
||||
</Button>
|
||||
<Button
|
||||
variant={objectFit === "fill" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleFitChange("fill");
|
||||
}}
|
||||
className="flex-1"
|
||||
disabled={!image}
|
||||
>
|
||||
Fill
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
<p><strong>Cover:</strong> Fill container, may crop image</p>
|
||||
<p><strong>Contain:</strong> Fit entire image, may show empty space</p>
|
||||
<p><strong>Fill:</strong> Stretch to fill container exactly</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-medium mb-2">Image Description</h3>
|
||||
<Textarea
|
||||
placeholder="Describe the image you want to generate..."
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="pt-2 border-t">
|
||||
<h4 className="text-sm font-medium mb-2">Quick Actions</h4>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setFocusPoint({ x: 50, y: 50 });
|
||||
setObjectFit("cover");
|
||||
saveImageProperties("cover", { x: 50, y: 50 });
|
||||
if (imageRef.current) {
|
||||
imageRef.current.style.objectFit = "cover";
|
||||
imageRef.current.style.objectPosition = "50% 50%";
|
||||
}
|
||||
}}
|
||||
className="flex-1"
|
||||
disabled={!image}
|
||||
>
|
||||
Reset to Default
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="generate" className="mt-4 space-y-4">
|
||||
<div></div>
|
||||
<div className="space-y-4">
|
||||
<div className="">
|
||||
<h3 className="text-sm font-medium mb-1">Current Prompt</h3>
|
||||
<Button
|
||||
onClick={handleGenerateImage}
|
||||
className="w-full"
|
||||
disabled={!prompt || isGenerating}
|
||||
>
|
||||
<Wand2 className="w-4 h-4 mr-2" />
|
||||
{isGenerating ? "Generating..." : "Generate Image"}
|
||||
</Button>
|
||||
|
||||
<p className="text-sm text-gray-500">{promptContent}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-medium mb-2">
|
||||
Image Description
|
||||
</h3>
|
||||
<Textarea
|
||||
placeholder="Describe the image you want to generate..."
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleGenerateImage}
|
||||
className="w-full"
|
||||
disabled={!prompt || isGenerating}
|
||||
>
|
||||
<Wand2 className="w-4 h-4 mr-2" />
|
||||
{isGenerating ? "Generating..." : "Generate Image"}
|
||||
</Button>
|
||||
{error && <p className="text-red-500 text-sm">{error}</p>}
|
||||
|
||||
{error && <p className="text-red-500 text-sm">{error}</p>}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{isGenerating || previewImages.length === 0
|
||||
? Array.from({ length: 4 }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={index}
|
||||
className="aspect-[4/3] w-full rounded-lg"
|
||||
/>
|
||||
))
|
||||
: previewImages.map((image, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => handleImageChange(image as string)}
|
||||
className="aspect-[4/3] w-full overflow-hidden rounded-lg border cursor-pointer"
|
||||
>
|
||||
<img
|
||||
src={
|
||||
image
|
||||
? getStaticFileUrl(image)
|
||||
: ""
|
||||
}
|
||||
alt={`Preview ${index + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{isGenerating || previewImages.length === 0
|
||||
? Array.from({ length: 4 }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={index}
|
||||
className="aspect-[4/3] w-full rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
: previewImages.map((image, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => handleImageChange(image as string)}
|
||||
className="aspect-[4/3] w-full overflow-hidden rounded-lg border cursor-pointer hover:border-blue-500 transition-colors"
|
||||
>
|
||||
{image && (
|
||||
<img
|
||||
src={image}
|
||||
alt={`Preview ${index + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="upload" className="mt-4 space-y-4">
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className={cn(
|
||||
"border-2 border-dashed rounded-lg p-8 text-center transition-colors",
|
||||
isUploading
|
||||
? "border-gray-400 bg-gray-50"
|
||||
: "border-gray-300"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
id="file-upload"
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
onChange={handleFileUpload}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
<label
|
||||
htmlFor="file-upload"
|
||||
</TabsContent>
|
||||
|
||||
{/* Upload Tab */}
|
||||
<TabsContent value="upload" className="mt-4 space-y-4">
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center",
|
||||
isUploading ? "cursor-wait" : "cursor-pointer"
|
||||
"border-2 border-dashed rounded-lg p-8 text-center transition-colors",
|
||||
isUploading
|
||||
? "border-gray-400 bg-gray-50"
|
||||
: "border-gray-300 hover:border-blue-400"
|
||||
)}
|
||||
>
|
||||
{isUploading ? (
|
||||
<div className="w-8 h-8 border-2 border-gray-400 border-t-transparent rounded-full animate-spin mb-2" />
|
||||
) : (
|
||||
<Upload className="w-8 h-8 text-gray-500 mb-2" />
|
||||
)}
|
||||
<span className="text-sm text-gray-600">
|
||||
{isUploading
|
||||
? "Uploading your image..."
|
||||
: "Click to upload an image"}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 mt-1">
|
||||
Maximum file size: 5MB
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{uploadError && (
|
||||
<p className="text-red-500 text-sm text-center">
|
||||
{uploadError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(uploadedImageUrl || isUploading) && (
|
||||
<div className="mt-4">
|
||||
<h3 className="text-sm font-medium mb-2">
|
||||
Uploaded Image Preview
|
||||
</h3>
|
||||
<div className="aspect-[4/3] relative rounded-lg overflow-hidden border border-gray-200">
|
||||
<input
|
||||
type="file"
|
||||
id="file-upload"
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
onChange={handleFileUpload}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
<label
|
||||
htmlFor="file-upload"
|
||||
className={cn(
|
||||
"flex flex-col items-center",
|
||||
isUploading ? "cursor-wait" : "cursor-pointer"
|
||||
)}
|
||||
>
|
||||
{isUploading ? (
|
||||
<div className="w-full h-full bg-gray-100 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-8 h-8 border-2 border-gray-400 border-t-transparent rounded-full animate-spin mb-2" />
|
||||
<span className="text-sm text-gray-500">
|
||||
Processing...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-8 h-8 border-2 border-gray-400 border-t-transparent rounded-full animate-spin mb-2" />
|
||||
) : (
|
||||
uploadedImageUrl && (
|
||||
<div
|
||||
onClick={() =>
|
||||
handleImageChange(uploadedImageUrl)
|
||||
}
|
||||
className="cursor-pointer group w-full h-full"
|
||||
>
|
||||
<img
|
||||
src={uploadedImageUrl}
|
||||
alt="Uploaded preview"
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all duration-200" />
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="bg-white/90 px-3 py-1 rounded-full text-sm font-medium">
|
||||
Click to use this image
|
||||
<Upload className="w-8 h-8 text-gray-500 mb-2" />
|
||||
)}
|
||||
<span className="text-sm text-gray-600">
|
||||
{isUploading
|
||||
? "Uploading your image..."
|
||||
: "Click to upload an image"}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 mt-1">
|
||||
Maximum file size: 5MB
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{uploadError && (
|
||||
<p className="text-red-500 text-sm text-center">
|
||||
{uploadError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(uploadedImageUrl || isUploading) && (
|
||||
<div className="mt-4">
|
||||
<h3 className="text-sm font-medium mb-2">
|
||||
Uploaded Image Preview
|
||||
</h3>
|
||||
<div className="aspect-[4/3] relative rounded-lg overflow-hidden border border-gray-200">
|
||||
{isUploading ? (
|
||||
<div className="w-full h-full bg-gray-100 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-8 h-8 border-2 border-gray-400 border-t-transparent rounded-full animate-spin mb-2" />
|
||||
<span className="text-sm text-gray-500">
|
||||
Processing...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
) : (
|
||||
uploadedImageUrl && (
|
||||
<div
|
||||
onClick={() =>
|
||||
handleImageChange(uploadedImageUrl)
|
||||
}
|
||||
className="cursor-pointer group w-full h-full"
|
||||
>
|
||||
<img
|
||||
src={uploadedImageUrl}
|
||||
alt="Uploaded preview"
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all duration-200" />
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="bg-white/90 px-3 py-1 rounded-full text-sm font-medium">
|
||||
Click to use this image
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
import React, { createContext, useContext, useRef, useEffect, ReactNode, useState } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { updateSlideImage, updateSlideIcon } from '../../../store/slices/presentationGeneration';
|
||||
import ImageEditor from './ImageEditor';
|
||||
import IconsEditor from './IconsEditor';
|
||||
|
||||
|
|
@ -53,6 +55,8 @@ export const SmartEditableProvider: React.FC<SmartEditableProviderProps> = ({
|
|||
rect: DOMRect;
|
||||
} | null>(null);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditMode || !containerRef.current || !slideData) return;
|
||||
|
||||
|
|
@ -63,7 +67,7 @@ export const SmartEditableProvider: React.FC<SmartEditableProviderProps> = ({
|
|||
|
||||
console.log('🔍 Starting smart detection with slideData:', slideData);
|
||||
|
||||
// Detect Images and Icons only (text is now handled by SmartText components)
|
||||
// Detect Images and Icons only (text is now handled by TiptapTextReplacer)
|
||||
const detectEditableElementsFromData = (data: any, path: string = '') => {
|
||||
if (!data || typeof data !== 'object') return;
|
||||
|
||||
|
|
@ -80,7 +84,16 @@ export const SmartEditableProvider: React.FC<SmartEditableProviderProps> = ({
|
|||
slideIndex,
|
||||
initialImage: data.__image_url__,
|
||||
promptContent: data.__image_prompt__ || '',
|
||||
imageIdx: elements.filter(e => e.type === 'image').length
|
||||
imageIdx: elements.filter(e => e.type === 'image').length,
|
||||
onImageChange: (newImageUrl: string, prompt?: string) => {
|
||||
console.log(`🖼️ Image changed at ${path}:`, newImageUrl);
|
||||
dispatch(updateSlideImage({
|
||||
slideIndex,
|
||||
dataPath: path,
|
||||
imageUrl: newImageUrl,
|
||||
prompt: prompt
|
||||
}));
|
||||
}
|
||||
}
|
||||
});
|
||||
console.log(`✅ Matched image to DOM element:`, imgElement);
|
||||
|
|
@ -103,12 +116,22 @@ export const SmartEditableProvider: React.FC<SmartEditableProviderProps> = ({
|
|||
index: elements.filter(e => e.type === 'icon').length,
|
||||
backgroundColor: '#3B82F6',
|
||||
hasBg: false,
|
||||
icon_prompt: data.__icon_query__ ? [data.__icon_query__] : []
|
||||
icon_prompt: data.__icon_query__ ? [data.__icon_query__] : [],
|
||||
onIconChange: (newIconUrl: string, query?: string) => {
|
||||
console.log(`🎯 Icon changed at ${path}:`, newIconUrl);
|
||||
dispatch(updateSlideIcon({
|
||||
slideIndex,
|
||||
dataPath: path,
|
||||
iconUrl: newIconUrl,
|
||||
query: query
|
||||
}));
|
||||
}
|
||||
}
|
||||
});
|
||||
console.log(`✅ Matched icon to DOM element:`, imgElement);
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively scan nested objects and arrays
|
||||
Object.keys(data).forEach(key => {
|
||||
const value = data[key];
|
||||
|
|
@ -168,7 +191,7 @@ export const SmartEditableProvider: React.FC<SmartEditableProviderProps> = ({
|
|||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [slideIndex, slideId, slideData, isEditMode]); // Removed editableElements from dependency array
|
||||
}, [slideIndex, slideId, slideData, isEditMode, dispatch]);
|
||||
|
||||
// Set up event listeners when editableElements change
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -40,9 +40,9 @@ const TiptapText: React.FC<TiptapTextProps> = ({
|
|||
},
|
||||
},
|
||||
onBlur: ({ editor }) => {
|
||||
const text = editor.getText();
|
||||
const markdown = editor?.storage.markdown.getMarkdown();
|
||||
if (onContentChange) {
|
||||
onContentChange(text);
|
||||
onContentChange(markdown);
|
||||
}
|
||||
},
|
||||
editable: !disabled,
|
||||
|
|
@ -61,10 +61,10 @@ const TiptapText: React.FC<TiptapTextProps> = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<div className="relative z-50 w-full">
|
||||
{!disabled && (
|
||||
<BubbleMenu editor={editor} tippyOptions={{ duration: 100 }}>
|
||||
<div className="flex bg-white rounded-lg shadow-lg p-2 gap-1 border border-gray-200 z-50">
|
||||
<div className="flex bg-white rounded-lg shadow-lg p-2 gap-1 border border-gray-200 z-50">
|
||||
<button
|
||||
onClick={() => editor?.chain().focus().toggleBold().run()}
|
||||
className={`p-1 rounded hover:bg-gray-100 transition-colors ${editor?.isActive("bold") ? "bg-blue-100 text-blue-600" : ""
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
"use client";
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import React, { useRef, useEffect, useState, ReactNode } from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
|
|
@ -11,7 +10,8 @@ interface TiptapTextReplacerProps {
|
|||
}>;
|
||||
children: ReactNode;
|
||||
slideData?: any;
|
||||
onContentChange?: (content: string, path: string) => void;
|
||||
slideIndex?: number;
|
||||
onContentChange?: (content: string, path: string, slideIndex?: number) => void;
|
||||
isEditMode?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -19,14 +19,12 @@ const TiptapTextReplacer: React.FC<TiptapTextReplacerProps> = ({
|
|||
children,
|
||||
slideData,
|
||||
layout,
|
||||
slideIndex,
|
||||
onContentChange = () => { },
|
||||
isEditMode = true
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [processedElements, setProcessedElements] = useState(new Set<HTMLElement>());
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditMode || !containerRef.current) return;
|
||||
|
||||
|
|
@ -46,6 +44,9 @@ const TiptapTextReplacer: React.FC<TiptapTextReplacerProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
// Skip if element is inside an ignored element tree
|
||||
if (isInIgnoredElementTree(htmlElement)) return;
|
||||
|
||||
// Get direct text content (not from child elements)
|
||||
const directTextContent = getDirectTextContent(htmlElement);
|
||||
const trimmedText = directTextContent.trim();
|
||||
|
|
@ -59,7 +60,6 @@ const TiptapTextReplacer: React.FC<TiptapTextReplacerProps> = ({
|
|||
// Skip certain element types that shouldn't be editable
|
||||
if (shouldSkipElement(htmlElement)) return;
|
||||
|
||||
console.log('Making element editable:', trimmedText, htmlElement);
|
||||
|
||||
// Get all computed styles to preserve them
|
||||
const computedStyles = window.getComputedStyle(htmlElement);
|
||||
|
|
@ -110,7 +110,7 @@ const TiptapTextReplacer: React.FC<TiptapTextReplacerProps> = ({
|
|||
content={trimmedText}
|
||||
onContentChange={(content: string) => {
|
||||
if (dataPath && onContentChange) {
|
||||
onContentChange(content, dataPath);
|
||||
onContentChange(content, dataPath, slideIndex);
|
||||
}
|
||||
}}
|
||||
placeholder="Enter text..."
|
||||
|
|
@ -120,6 +120,56 @@ const TiptapTextReplacer: React.FC<TiptapTextReplacerProps> = ({
|
|||
});
|
||||
};
|
||||
|
||||
// Function to check if element is inside an ignored element tree
|
||||
const isInIgnoredElementTree = (element: HTMLElement): boolean => {
|
||||
// List of element types that should be ignored entirely with all their children
|
||||
const ignoredElementTypes = [
|
||||
'TABLE', 'TBODY', 'THEAD', 'TFOOT', 'TR', 'TD', 'TH', // Table elements
|
||||
'SVG', 'G', 'PATH', 'CIRCLE', 'RECT', 'LINE', // SVG elements
|
||||
'CANVAS', // Canvas element
|
||||
'VIDEO', 'AUDIO', // Media elements
|
||||
'IFRAME', 'EMBED', 'OBJECT', // Embedded content
|
||||
'SELECT', 'OPTION', 'OPTGROUP', // Select dropdown elements
|
||||
'SCRIPT', 'STYLE', 'NOSCRIPT', // Script/style elements
|
||||
];
|
||||
|
||||
// List of class patterns that indicate ignored element trees
|
||||
const ignoredClassPatterns = [
|
||||
'chart', 'graph', 'visualization', // Chart/graph components
|
||||
'menu', 'dropdown', 'tooltip', // UI components
|
||||
'editor', 'wysiwyg', // Editor components
|
||||
'calendar', 'datepicker', // Date picker components
|
||||
'slider', 'carousel', // Interactive components
|
||||
];
|
||||
|
||||
// Check if current element or any parent is in ignored list
|
||||
let currentElement: HTMLElement | null = element;
|
||||
while (currentElement) {
|
||||
// Check element type
|
||||
if (ignoredElementTypes.includes(currentElement.tagName)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check class patterns
|
||||
const className = currentElement.className.length > 0 ? currentElement.className.toLowerCase() : '';
|
||||
if (ignoredClassPatterns.some(pattern => className.includes(pattern))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for specific attributes that indicate non-text content
|
||||
if (currentElement.hasAttribute('contenteditable') ||
|
||||
currentElement.hasAttribute('data-chart') ||
|
||||
currentElement.hasAttribute('data-visualization') ||
|
||||
currentElement.hasAttribute('data-interactive')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
currentElement = currentElement.parentElement;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Helper function to get only direct text content (not from children)
|
||||
const getDirectTextContent = (element: HTMLElement): string => {
|
||||
let text = '';
|
||||
|
|
@ -155,7 +205,7 @@ const TiptapTextReplacer: React.FC<TiptapTextReplacerProps> = ({
|
|||
return true;
|
||||
}
|
||||
|
||||
// Skip elements that contain interactive content
|
||||
// Skip elements that contain interactive content (simplified since we now use isInIgnoredElementTree)
|
||||
if (element.querySelector('img, svg, button, input, textarea, select, a[href]')) {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -163,7 +213,7 @@ const TiptapTextReplacer: React.FC<TiptapTextReplacerProps> = ({
|
|||
// Skip container elements (elements that primarily serve as layout containers)
|
||||
const containerClasses = ['grid', 'flex', 'space-', 'gap-', 'container', 'wrapper'];
|
||||
const hasContainerClass = containerClasses.some(cls =>
|
||||
element.className.includes(cls)
|
||||
element.className.length > 0 ? element.className.includes(cls) : false
|
||||
);
|
||||
if (hasContainerClass) return true;
|
||||
|
||||
|
|
@ -208,7 +258,7 @@ const TiptapTextReplacer: React.FC<TiptapTextReplacerProps> = ({
|
|||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [slideData, isEditMode]);
|
||||
}, [slideData, isEditMode, slideIndex]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="tiptap-text-replacer">
|
||||
|
|
|
|||
|
|
@ -1,500 +0,0 @@
|
|||
import { Slide } from "../types/slide";
|
||||
|
||||
import Type1Layout from "./slide_layouts/Type1Layout";
|
||||
import Type2Layout from "./slide_layouts/Type2Layout";
|
||||
import Type4Layout from "./slide_layouts/Type4Layout";
|
||||
import Type5Layout from "./slide_layouts/Type5Layout";
|
||||
import Type6Layout from "./slide_layouts/Type6Layout";
|
||||
import Type7Layout from "./slide_layouts/Type7Layout";
|
||||
import Type8Layout from "./slide_layouts/Type8Layout";
|
||||
import Type9Layout from "./slide_layouts/Type9Layout";
|
||||
|
||||
|
||||
import { Chart, ChartSettings } from "@/store/slices/presentationGeneration";
|
||||
|
||||
import { Pie, PieChart, Cell, CartesianGrid, Label } from "recharts";
|
||||
import {
|
||||
LineChart,
|
||||
Bar,
|
||||
Legend,
|
||||
BarChart,
|
||||
Tooltip,
|
||||
YAxis,
|
||||
Line,
|
||||
XAxis,
|
||||
} from "recharts";
|
||||
import { ResponsiveContainer } from "recharts";
|
||||
|
||||
import { ThemeColors } from "../store/themeSlice";
|
||||
import { isDarkColor } from "../utils/others";
|
||||
|
||||
import {
|
||||
formatTooltipValue,
|
||||
formatYAxisTick,
|
||||
transformedData,
|
||||
} from "../utils/chart";
|
||||
|
||||
export const renderSlideContent = (slide: Slide, language: string) => {
|
||||
switch (slide.type) {
|
||||
case 1:
|
||||
return (
|
||||
<Type1Layout
|
||||
slideIndex={slide.index}
|
||||
title={slide.content.title}
|
||||
slideId={slide.id}
|
||||
description={
|
||||
typeof slide.content.body === "string"
|
||||
? slide.content.body
|
||||
: slide.content.body[0]?.description || ""
|
||||
}
|
||||
images={slide.images || []}
|
||||
image_prompts={slide.content.image_prompts || []}
|
||||
properties={slide.properties}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<Type2Layout
|
||||
title={slide.content.title}
|
||||
slideId={slide.id}
|
||||
slideIndex={slide.index}
|
||||
body={Array.isArray(slide.content.body) ? slide.content.body : []}
|
||||
language={language || "English"}
|
||||
design_index={slide.design_index || 2}
|
||||
/>
|
||||
);
|
||||
|
||||
case 4:
|
||||
return (
|
||||
<Type4Layout
|
||||
title={slide.content.title}
|
||||
slideId={slide.id}
|
||||
slideIndex={slide.index}
|
||||
images={slide.images || []}
|
||||
body={Array.isArray(slide.content.body) ? slide.content.body : []}
|
||||
image_prompts={slide.content.image_prompts || []}
|
||||
properties={slide.properties}
|
||||
/>
|
||||
);
|
||||
|
||||
case 5:
|
||||
const isFullSizeGraph =
|
||||
slide.content.graph?.data.categories.length > 4 &&
|
||||
slide.content.graph.type !== "pie";
|
||||
return (
|
||||
<Type5Layout
|
||||
title={slide.content.title}
|
||||
slideId={slide.id}
|
||||
slideIndex={slide.index}
|
||||
description={(slide.content.body as string) || ""}
|
||||
isFullSizeGraph={isFullSizeGraph}
|
||||
graphData={slide.content.graph}
|
||||
/>
|
||||
);
|
||||
|
||||
case 6:
|
||||
return (
|
||||
<Type6Layout
|
||||
title={slide.content.title}
|
||||
slideId={slide.id}
|
||||
slideIndex={slide.index}
|
||||
description={slide.content.description || ""}
|
||||
body={Array.isArray(slide.content.body) ? slide.content.body : []}
|
||||
language={language || "English"}
|
||||
/>
|
||||
);
|
||||
|
||||
case 7:
|
||||
return (
|
||||
<Type7Layout
|
||||
title={slide.content.title}
|
||||
slideId={slide.id}
|
||||
slideIndex={slide.index}
|
||||
body={Array.isArray(slide.content.body) ? slide.content.body : []}
|
||||
icons={slide.icons || []}
|
||||
icon_queries={slide.content.icon_queries || []}
|
||||
/>
|
||||
);
|
||||
|
||||
case 8:
|
||||
return (
|
||||
<Type8Layout
|
||||
title={slide.content.title}
|
||||
slideId={slide.id}
|
||||
body={Array.isArray(slide.content.body) ? slide.content.body : []}
|
||||
slideIndex={slide.index}
|
||||
description={slide.content.description || ""}
|
||||
icons={slide.icons || []}
|
||||
icon_queries={slide.content.icon_queries || []}
|
||||
/>
|
||||
);
|
||||
|
||||
case 9:
|
||||
return (
|
||||
<Type9Layout
|
||||
slideIndex={slide.index}
|
||||
slideId={slide.id}
|
||||
title={slide.content.title}
|
||||
// @ts-ignore
|
||||
body={slide.content.body}
|
||||
language={language || "English"}
|
||||
graphData={slide.content.graph}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
// CHART RENDERING
|
||||
export const renderChart = (
|
||||
localChartData: Chart,
|
||||
isMini: boolean = false,
|
||||
theme: ThemeColors,
|
||||
chartSettings?: ChartSettings
|
||||
) => {
|
||||
const chartColors = theme.chartColors || [];
|
||||
|
||||
const renderCustomizedLabel = ({
|
||||
cx,
|
||||
cy,
|
||||
midAngle,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
percent,
|
||||
index,
|
||||
}: any) => {
|
||||
const RADIAN = Math.PI / 180;
|
||||
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
|
||||
const x = cx + radius * Math.cos(-midAngle * RADIAN);
|
||||
const y = cy + radius * Math.sin(-midAngle * RADIAN);
|
||||
const isDark = isDarkColor(theme.chartColors[index % chartColors.length]);
|
||||
|
||||
return (
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
fill={isDark ? "#ffffff" : "#000000"}
|
||||
style={{ cursor: "pointer" }}
|
||||
textAnchor={x > cx ? "start" : "end"}
|
||||
dominantBaseline="central"
|
||||
>
|
||||
{`${(percent * 100).toFixed(0)}%`}
|
||||
</text>
|
||||
);
|
||||
};
|
||||
|
||||
// New function for outside labels
|
||||
const renderOutsideLabel = ({
|
||||
cx,
|
||||
cy,
|
||||
midAngle,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
percent,
|
||||
index,
|
||||
name,
|
||||
}: any) => {
|
||||
const RADIAN = Math.PI / 180;
|
||||
// Position the label further outside the pie
|
||||
const radius = outerRadius * 1.2;
|
||||
const x = cx + radius * Math.cos(-midAngle * RADIAN);
|
||||
const y = cy + radius * Math.sin(-midAngle * RADIAN);
|
||||
|
||||
return (
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
fill={theme.slideTitle}
|
||||
style={{ cursor: "pointer" }}
|
||||
textAnchor={x > cx ? "start" : "end"}
|
||||
dominantBaseline="central"
|
||||
>
|
||||
{`${(percent * 100).toFixed(0)}%`}
|
||||
</text>
|
||||
);
|
||||
};
|
||||
|
||||
if (!localChartData) return null;
|
||||
switch (localChartData.type) {
|
||||
case "line":
|
||||
return (
|
||||
<ResponsiveContainer
|
||||
id="line-chart-container"
|
||||
width="100%"
|
||||
height={isMini ? 100 : 300}
|
||||
>
|
||||
<LineChart
|
||||
className="w-full"
|
||||
data={transformedData(localChartData)}
|
||||
style={{ cursor: "pointer" }}
|
||||
margin={{ bottom: !isMini ? 30 : 0, right: 30, left: 10, top: 20 }}
|
||||
>
|
||||
{chartSettings?.showGrid && (
|
||||
<CartesianGrid
|
||||
vertical={false}
|
||||
stroke={theme.slideDescription}
|
||||
opacity={0.2}
|
||||
/>
|
||||
)}
|
||||
{!isMini && chartSettings?.showAxisLabel && (
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tickSize={10}
|
||||
angle={-10}
|
||||
height={!isMini ? 30 : 0}
|
||||
interval={0}
|
||||
dy={!isMini ? 10 : 0}
|
||||
dx={!isMini ? -15 : 0}
|
||||
tick={{
|
||||
fill: theme.slideTitle,
|
||||
fontSize: 14,
|
||||
alignmentBaseline: "middle",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!isMini && chartSettings?.showAxisLabel && (
|
||||
<YAxis
|
||||
tick={{ fill: theme.slideTitle }}
|
||||
tickFormatter={formatYAxisTick}
|
||||
padding={{ top: 15 }}
|
||||
>
|
||||
<Label
|
||||
value={localChartData.unit || ""}
|
||||
position="top"
|
||||
style={{
|
||||
textTransform: "capitalize",
|
||||
textAnchor: "start",
|
||||
fontSize: "16px",
|
||||
fill: theme.slideTitle,
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
/>
|
||||
</YAxis>
|
||||
)}
|
||||
<Tooltip
|
||||
cursor={{ fill: "transparent" }}
|
||||
contentStyle={{
|
||||
backgroundColor: theme.slideBox,
|
||||
color: theme.slideTitle,
|
||||
border: "none",
|
||||
}}
|
||||
itemStyle={{
|
||||
color: theme.slideTitle,
|
||||
}}
|
||||
/>
|
||||
{!isMini && chartSettings?.showLegend && (
|
||||
<Legend verticalAlign="top" align="center" />
|
||||
)}
|
||||
{localChartData.data.series.map((serie, index) => (
|
||||
<Line
|
||||
isAnimationActive={false}
|
||||
key={serie.name || `Series ${index + 1}`}
|
||||
type="monotone"
|
||||
strokeWidth={2}
|
||||
dataKey={serie.name || `Series ${index + 1}`}
|
||||
stroke={chartColors[index % chartColors.length]}
|
||||
style={{ cursor: "pointer" }}
|
||||
// label={(chartSettings?.showDataLabel && localChartData.data.series.length === 1) ? {
|
||||
// position: chartSettings?.dataLabel.dataLabelPosition === "Outside" ? "top" : "center",
|
||||
// formatter: (value: number) => formatYAxisTick(value),
|
||||
// fill: chartSettings?.dataLabel.dataLabelPosition === "Outside" ? theme.slideTitle : '#ffffff',
|
||||
// fontWeight: 'bold',
|
||||
// fontSize: '12px',
|
||||
// fontFamily: theme.fontFamily
|
||||
// } : undefined}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
|
||||
case "pie":
|
||||
return (
|
||||
<ResponsiveContainer
|
||||
id="pie-chart-container"
|
||||
width="100%"
|
||||
height={isMini ? 100 : 300}
|
||||
>
|
||||
<PieChart>
|
||||
<Pie
|
||||
isAnimationActive={false}
|
||||
data={transformedData(localChartData)}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
style={{ cursor: "pointer" }}
|
||||
label={
|
||||
chartSettings?.showDataLabel
|
||||
? chartSettings?.dataLabel.dataLabelPosition === "Inside"
|
||||
? renderCustomizedLabel
|
||||
: renderOutsideLabel
|
||||
: false
|
||||
}
|
||||
fill={theme.slideTitle}
|
||||
paddingAngle={2}
|
||||
labelLine={false}
|
||||
outerRadius={
|
||||
chartSettings?.dataLabel.dataLabelPosition === "Outside"
|
||||
? "80%"
|
||||
: "90%"
|
||||
}
|
||||
>
|
||||
{transformedData(localChartData).map((entry: any, index: any) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={chartColors[index % chartColors.length]}
|
||||
focusable={false}
|
||||
stroke="none"
|
||||
style={{
|
||||
border: "none",
|
||||
outline: "none",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value) =>
|
||||
formatTooltipValue(localChartData, value as number)
|
||||
}
|
||||
contentStyle={{
|
||||
backgroundColor: theme.slideBox,
|
||||
color: theme.slideTitle,
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
}}
|
||||
itemStyle={{
|
||||
color: theme.slideTitle,
|
||||
}}
|
||||
/>
|
||||
{!isMini && chartSettings?.showLegend && (
|
||||
<Legend verticalAlign="top" align="center" />
|
||||
)}
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
|
||||
case "bar":
|
||||
default:
|
||||
return (
|
||||
<ResponsiveContainer
|
||||
id="bar-chart-container"
|
||||
width="100%"
|
||||
height={isMini ? 100 : 330}
|
||||
>
|
||||
<BarChart
|
||||
data={transformedData(localChartData)}
|
||||
margin={{ bottom: !isMini ? 30 : 0, top: 20 }}
|
||||
>
|
||||
{chartSettings?.showGrid && (
|
||||
<CartesianGrid
|
||||
vertical={false}
|
||||
stroke={theme.slideDescription}
|
||||
opacity={0.2}
|
||||
/>
|
||||
)}
|
||||
{!isMini && chartSettings?.showAxisLabel && (
|
||||
<XAxis
|
||||
stroke={theme.slideTitle}
|
||||
className=""
|
||||
dataKey="name"
|
||||
tickSize={10}
|
||||
angle={-10}
|
||||
height={!isMini ? 40 : 0}
|
||||
interval={0}
|
||||
dy={!isMini ? 20 : 0}
|
||||
dx={!isMini ? -10 : 0}
|
||||
tick={{
|
||||
fill: theme.slideTitle,
|
||||
fontSize: 14,
|
||||
alignmentBaseline: "middle",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!isMini && chartSettings?.showAxisLabel && (
|
||||
<YAxis
|
||||
stroke={theme.slideTitle}
|
||||
tick={{ fill: theme.slideTitle }}
|
||||
tickFormatter={formatYAxisTick}
|
||||
padding={{ top: 20 }}
|
||||
>
|
||||
<Label
|
||||
value={localChartData.unit || ""}
|
||||
position="top"
|
||||
style={{
|
||||
textTransform: "capitalize",
|
||||
textAnchor: "start",
|
||||
fontSize: "16px",
|
||||
fill: theme.slideTitle,
|
||||
fontWeight: "bold",
|
||||
width: "fit",
|
||||
margin: "0 auto",
|
||||
}}
|
||||
/>
|
||||
</YAxis>
|
||||
)}
|
||||
<Tooltip
|
||||
cursor={{ fill: "transparent" }}
|
||||
contentStyle={{
|
||||
backgroundColor: theme.slideBox,
|
||||
color: theme.slideTitle,
|
||||
border: "none",
|
||||
}}
|
||||
itemStyle={{
|
||||
color: theme.slideTitle,
|
||||
}}
|
||||
/>
|
||||
{!isMini && chartSettings?.showLegend && (
|
||||
<Legend verticalAlign="top" align="center" />
|
||||
)}
|
||||
{localChartData &&
|
||||
localChartData.data &&
|
||||
localChartData.data.series &&
|
||||
localChartData.data.series.map((serie, index) => (
|
||||
<Bar
|
||||
isAnimationActive={false}
|
||||
key={serie.name || `Series ${index + 1}`}
|
||||
dataKey={serie.name || `Series ${index + 1}`}
|
||||
fill={chartColors[index % chartColors.length]}
|
||||
barSize={50}
|
||||
style={{ cursor: "pointer" }}
|
||||
radius={[5, 8, 0, 0]}
|
||||
label={
|
||||
chartSettings?.showDataLabel
|
||||
? {
|
||||
position:
|
||||
chartSettings?.dataLabel.dataLabelPosition ===
|
||||
"Outside"
|
||||
? "top"
|
||||
: chartSettings?.dataLabel.dataLabelAlignment ===
|
||||
"Base"
|
||||
? "insideBottom"
|
||||
: chartSettings?.dataLabel.dataLabelAlignment ===
|
||||
"Center"
|
||||
? "center"
|
||||
: "insideTop",
|
||||
formatter: (value: number) => formatYAxisTick(value),
|
||||
fill:
|
||||
chartSettings?.dataLabel.dataLabelPosition ===
|
||||
"Outside"
|
||||
? theme.slideTitle
|
||||
: "#ffffff",
|
||||
fontWeight: "bold",
|
||||
fontSize: "14px",
|
||||
fontFamily: theme.fontFamily,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
@ -70,84 +70,92 @@ export const LayoutProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||
const fileMap = new Map<string, { fileName: string; groupName: string }>();
|
||||
const groupedLayouts = new Map<string, LayoutInfo[]>();
|
||||
|
||||
// Start preloading process
|
||||
setIsPreloading(true);
|
||||
|
||||
for (const groupData of groupedLayoutsData) {
|
||||
try {
|
||||
for (const groupData of groupedLayoutsData) {
|
||||
|
||||
// Initialize group
|
||||
if (!layoutsByGroup.has(groupData.groupName)) {
|
||||
layoutsByGroup.set(groupData.groupName, new Set());
|
||||
}
|
||||
|
||||
// group settings or default settings
|
||||
const settings = groupData.settings || {
|
||||
description: `${groupData.groupName} presentation layouts`,
|
||||
ordered: false,
|
||||
isDefault: false
|
||||
};
|
||||
|
||||
groupSettingsMap.set(groupData.groupName, settings);
|
||||
const groupLayouts: LayoutInfo[] = [];
|
||||
|
||||
for (const fileName of groupData.files) {
|
||||
try {
|
||||
const file = fileName.replace('.tsx', '').replace('.ts', '');
|
||||
|
||||
const module = await import(`@/presentation-layouts/${groupData.groupName}/${file}`);
|
||||
|
||||
|
||||
if (!module.default) {
|
||||
toast({
|
||||
title: `${file} has no default export`,
|
||||
description: 'Please ensure the layout file exports a default component',
|
||||
});
|
||||
console.warn(`❌ ${file} has no default export`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!module.Schema) {
|
||||
toast({
|
||||
title: `${file} has no Schema export`,
|
||||
description: 'Please ensure the layout file exports a Schema',
|
||||
});
|
||||
console.warn(`❌ ${file} has no Schema export`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const originalLayoutId = module.layoutId || file.toLowerCase().replace(/layout$/, '');
|
||||
const uniqueKey = `${groupData.groupName}:${originalLayoutId}`;
|
||||
const layoutName = module.layoutName || file.replace(/([A-Z])/g, ' $1').trim();
|
||||
const layoutDescription = module.layoutDescription || `${layoutName} layout for presentations`;
|
||||
|
||||
|
||||
const jsonSchema = z.toJSONSchema(module.Schema, {
|
||||
override: (ctx) => {
|
||||
delete ctx.jsonSchema.default;
|
||||
},
|
||||
});
|
||||
|
||||
const layout: LayoutInfo = {
|
||||
id: originalLayoutId,
|
||||
name: layoutName,
|
||||
description: layoutDescription,
|
||||
json_schema: jsonSchema,
|
||||
groupName: groupData.groupName,
|
||||
};
|
||||
|
||||
|
||||
layoutsById.set(uniqueKey, layout);
|
||||
layoutsByGroup.get(groupData.groupName)!.add(originalLayoutId);
|
||||
fileMap.set(uniqueKey, { fileName, groupName: groupData.groupName });
|
||||
groupLayouts.push(layout);
|
||||
layouts.push(layout);
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error(`💥 Error extracting schema for ${fileName} from ${groupData.groupName}:`, error);
|
||||
// Initialize group
|
||||
if (!layoutsByGroup.has(groupData.groupName)) {
|
||||
layoutsByGroup.set(groupData.groupName, new Set());
|
||||
}
|
||||
}
|
||||
|
||||
// Cache grouped layouts
|
||||
groupedLayouts.set(groupData.groupName, groupLayouts);
|
||||
// group settings or default settings
|
||||
const settings = groupData.settings || {
|
||||
description: `${groupData.groupName} presentation layouts`,
|
||||
ordered: false,
|
||||
isDefault: false
|
||||
};
|
||||
|
||||
groupSettingsMap.set(groupData.groupName, settings);
|
||||
const groupLayouts: LayoutInfo[] = [];
|
||||
|
||||
for (const fileName of groupData.files) {
|
||||
try {
|
||||
const file = fileName.replace('.tsx', '').replace('.ts', '');
|
||||
|
||||
const module = await import(`@/presentation-layouts/${groupData.groupName}/${file}`);
|
||||
|
||||
if (!module.default) {
|
||||
toast({
|
||||
title: `${file} has no default export`,
|
||||
description: 'Please ensure the layout file exports a default component',
|
||||
});
|
||||
console.warn(`❌ ${file} has no default export`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!module.Schema) {
|
||||
toast({
|
||||
title: `${file} has no Schema export`,
|
||||
description: 'Please ensure the layout file exports a Schema',
|
||||
});
|
||||
console.warn(`❌ ${file} has no Schema export`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Cache the layout component immediately after import
|
||||
const cacheKey = createCacheKey(groupData.groupName, fileName);
|
||||
if (!layoutCache.has(cacheKey)) {
|
||||
layoutCache.set(cacheKey, module.default);
|
||||
}
|
||||
|
||||
const originalLayoutId = module.layoutId || file.toLowerCase().replace(/layout$/, '');
|
||||
const uniqueKey = `${groupData.groupName}:${originalLayoutId}`;
|
||||
const layoutName = module.layoutName || file.replace(/([A-Z])/g, ' $1').trim();
|
||||
const layoutDescription = module.layoutDescription || `${layoutName} layout for presentations`;
|
||||
|
||||
const jsonSchema = z.toJSONSchema(module.Schema, {
|
||||
override: (ctx) => {
|
||||
delete ctx.jsonSchema.default;
|
||||
},
|
||||
});
|
||||
|
||||
const layout: LayoutInfo = {
|
||||
id: uniqueKey,
|
||||
name: layoutName,
|
||||
description: layoutDescription,
|
||||
json_schema: jsonSchema,
|
||||
groupName: groupData.groupName,
|
||||
};
|
||||
|
||||
layoutsById.set(uniqueKey, layout);
|
||||
layoutsByGroup.get(groupData.groupName)!.add(uniqueKey);
|
||||
fileMap.set(uniqueKey, { fileName, groupName: groupData.groupName });
|
||||
groupLayouts.push(layout);
|
||||
layouts.push(layout);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`💥 Error extracting schema for ${fileName} from ${groupData.groupName}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Cache grouped layouts
|
||||
groupedLayouts.set(groupData.groupName, groupLayouts);
|
||||
}
|
||||
} finally {
|
||||
setIsPreloading(false);
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -185,8 +193,7 @@ export const LayoutProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||
const data = await buildData(groupedLayoutsData);
|
||||
setLayoutData(data);
|
||||
|
||||
// Preload layouts after loading schema
|
||||
await preloadLayouts(data.fileMap);
|
||||
// The preloading is now handled within buildData
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to load layouts';
|
||||
setError(errorMessage);
|
||||
|
|
@ -196,33 +203,6 @@ export const LayoutProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||
}
|
||||
};
|
||||
|
||||
const preloadLayouts = async (fileMap: Map<string, { fileName: string; groupName: string }>) => {
|
||||
setIsPreloading(true);
|
||||
try {
|
||||
const layoutPromises = Array.from(fileMap.entries()).map(async ([layoutId, { fileName, groupName }]) => {
|
||||
const cacheKey = createCacheKey(groupName, fileName);
|
||||
if (!layoutCache.has(cacheKey)) {
|
||||
const layoutName = fileName.replace('.tsx', '').replace('.ts', '');
|
||||
|
||||
const Layout = dynamic(
|
||||
() => import(`@/presentation-layouts/${groupName}/${layoutName}`),
|
||||
{
|
||||
loading: () => <div className="w-full aspect-[16/9] bg-gray-100 animate-pulse rounded-lg" />,
|
||||
ssr: false,
|
||||
}
|
||||
) as React.ComponentType<{ data: any }>;
|
||||
|
||||
layoutCache.set(cacheKey, Layout);
|
||||
}
|
||||
});
|
||||
await Promise.all(layoutPromises);
|
||||
} catch (error) {
|
||||
console.error('Error preloading layouts:', error);
|
||||
} finally {
|
||||
setIsPreloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getLayout = (layoutId: string): React.ComponentType<{ data: any }> | null => {
|
||||
if (!layoutData) return null;
|
||||
|
||||
|
|
@ -230,9 +210,7 @@ export const LayoutProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||
|
||||
// Search through all fileMap entries to find the layout
|
||||
for (const [key, info] of Array.from(layoutData.fileMap.entries())) {
|
||||
// Extract original layout ID from unique key (format: "groupName:layoutId")
|
||||
const originalId = key.split(':')[1];
|
||||
if (originalId === layoutId) {
|
||||
if (key === layoutId) {
|
||||
fileInfo = info;
|
||||
break;
|
||||
}
|
||||
|
|
@ -269,8 +247,7 @@ export const LayoutProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||
|
||||
// Search through all entries to find the layout (since we don't know the group)
|
||||
for (const [key, layout] of Array.from(layoutData.layoutsById.entries())) {
|
||||
const originalId = key.split(':')[1];
|
||||
if (originalId === layoutId) {
|
||||
if (key === layoutId) {
|
||||
return layout;
|
||||
}
|
||||
}
|
||||
|
|
@ -279,8 +256,7 @@ export const LayoutProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||
|
||||
const getLayoutByIdAndGroup = (layoutId: string, groupName: string): LayoutInfo | null => {
|
||||
if (!layoutData) return null;
|
||||
const uniqueKey = `${groupName}:${layoutId}`;
|
||||
return layoutData.layoutsById.get(uniqueKey) || null;
|
||||
return layoutData.layoutsById.get(layoutId) || null;
|
||||
};
|
||||
|
||||
const getLayoutsByGroup = (groupName: string): LayoutInfo[] => {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
'use client'
|
||||
import React, { useMemo } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useLayout } from '../context/LayoutContext';
|
||||
import { SmartEditableProvider } from '../components/SmartEditableWrapper';
|
||||
import EditableLayoutWrapper from '../components/EditableLayoutWrapper';
|
||||
import TiptapTextReplacer from '../components/TiptapTextReplacer';
|
||||
import { updateSlideContent } from '../../../store/slices/presentationGeneration';
|
||||
|
||||
export const useGroupLayouts = () => {
|
||||
const dispatch = useDispatch();
|
||||
const {
|
||||
getLayoutByIdAndGroup,
|
||||
getLayoutsByGroup,
|
||||
|
|
@ -30,7 +33,7 @@ export const useGroupLayouts = () => {
|
|||
};
|
||||
}, [getLayoutsByGroup]);
|
||||
|
||||
// Render slide content with group validation and automatic Tiptap text editing
|
||||
// Render slide content with group validation, automatic Tiptap text editing, and editable images/icons
|
||||
const renderSlideContent = useMemo(() => {
|
||||
return (slide: any, isEditMode: boolean = true) => {
|
||||
const Layout = getGroupLayout(slide.layout, slide.layout_group);
|
||||
|
|
@ -46,29 +49,37 @@ export const useGroupLayouts = () => {
|
|||
|
||||
if (isEditMode) {
|
||||
return (
|
||||
<SmartEditableProvider
|
||||
<EditableLayoutWrapper
|
||||
slideIndex={slide.index}
|
||||
slideId={slide.id || `slide-${slide.index}`}
|
||||
slideData={slide.content}
|
||||
isEditMode={isEditMode}
|
||||
>
|
||||
<TiptapTextReplacer
|
||||
slideData={slide.content}
|
||||
slideIndex={slide.index}
|
||||
isEditMode={isEditMode}
|
||||
layout={Layout}
|
||||
onContentChange={(content: string, dataPath: string) => {
|
||||
console.log(`Text content changed at ${dataPath}:`, content);
|
||||
onContentChange={(content: string, dataPath: string, slideIndex?: number) => {
|
||||
console.log(`Text content changed at slide ${slideIndex}, path ${dataPath}:`, content);
|
||||
|
||||
// Dispatch Redux action to update slide content
|
||||
if (dataPath && slideIndex !== undefined) {
|
||||
dispatch(updateSlideContent({
|
||||
slideIndex: slideIndex,
|
||||
dataPath: dataPath,
|
||||
content: content
|
||||
}));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Layout data={slide.content} />
|
||||
</TiptapTextReplacer>
|
||||
</SmartEditableProvider>
|
||||
</EditableLayoutWrapper>
|
||||
);
|
||||
}
|
||||
return <Layout data={slide.content} />;
|
||||
};
|
||||
}, [getGroupLayout]);
|
||||
}, [getGroupLayout, dispatch]);
|
||||
|
||||
return {
|
||||
getGroupLayout,
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ const LayoutSelection: React.FC<LayoutSelectionProps> = ({
|
|||
const Groups: LayoutGroup[] = groups.map(groupName => {
|
||||
const layouts = getLayoutsByGroup(groupName);
|
||||
const settings = getGroupSetting(groupName);
|
||||
|
||||
return {
|
||||
id: groupName,
|
||||
name: groupName,
|
||||
|
|
|
|||
|
|
@ -47,7 +47,6 @@ import Modal from "./Modal";
|
|||
|
||||
import Announcement from "@/components/Announcement";
|
||||
import { getFontLink, getStaticFileUrl } from "../../utils/others";
|
||||
import JSPowerPointExtractor from "../../components/JSPowerPointExtractor";
|
||||
|
||||
|
||||
const Header = ({
|
||||
|
|
@ -108,13 +107,7 @@ const Header = ({
|
|||
themeColors.slideBox
|
||||
);
|
||||
|
||||
// Save in background
|
||||
await PresentationGenerationApi.setThemeColors(presentation_id, {
|
||||
name: themeType,
|
||||
colors: {
|
||||
...themeColors,
|
||||
},
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to update theme:", error);
|
||||
toast({
|
||||
|
|
|
|||
|
|
@ -8,14 +8,14 @@ import SidePanel from "../components/SidePanel";
|
|||
import SlideContent from "../components/SlideContent";
|
||||
import LoadingState from "../../components/LoadingState";
|
||||
import Header from "../components/Header";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { AlertCircle, Loader2 } from "lucide-react";
|
||||
import Help from "./Help";
|
||||
import {
|
||||
usePresentationStreaming,
|
||||
usePresentationData,
|
||||
usePresentationNavigation
|
||||
usePresentationNavigation,
|
||||
useAutoSave
|
||||
} from "../hooks";
|
||||
import { PresentationPageProps } from "../types";
|
||||
|
||||
|
|
@ -26,7 +26,6 @@ const PresentationPage: React.FC<PresentationPageProps> = ({ presentation_id })
|
|||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const [isMobilePanelOpen, setIsMobilePanelOpen] = useState(false);
|
||||
const [autoSaveLoading, setAutoSaveLoading] = useState(false);
|
||||
|
||||
// Redux state
|
||||
const { currentTheme, currentColors } = useSelector(
|
||||
|
|
@ -36,13 +35,19 @@ const PresentationPage: React.FC<PresentationPageProps> = ({ presentation_id })
|
|||
(state: RootState) => state.presentationGeneration
|
||||
);
|
||||
|
||||
// Auto-save functionality
|
||||
const { isSaving } = useAutoSave({
|
||||
debounceMs: 2000,
|
||||
enabled: !!presentationData && !isStreaming,
|
||||
|
||||
});
|
||||
|
||||
// Custom hooks
|
||||
const { fetchUserSlides, handleDeleteSlide } = usePresentationData(
|
||||
presentation_id,
|
||||
setLoading,
|
||||
setError
|
||||
);
|
||||
|
||||
const {
|
||||
isPresentMode,
|
||||
stream,
|
||||
|
|
@ -98,33 +103,29 @@ const PresentationPage: React.FC<PresentationPageProps> = ({ presentation_id })
|
|||
role="alert"
|
||||
>
|
||||
<AlertCircle className="w-16 h-16 mb-4 text-red-500" />
|
||||
<strong className="font-bold text-4xl mb-2">Oops!</strong>
|
||||
<p className="block text-2xl py-2">
|
||||
We encountered an issue loading your presentation.
|
||||
<h2 className="text-xl font-semibold mb-2">
|
||||
Something went wrong
|
||||
</h2>
|
||||
<p className="text-center mb-4">
|
||||
We couldn't load your presentation. Please try again.
|
||||
</p>
|
||||
<p className="text-lg py-2">
|
||||
Please check your internet connection or try again later.
|
||||
</p>
|
||||
<Button
|
||||
className="mt-4 bg-red-500 text-white hover:bg-red-600 focus:ring-4 focus:ring-red-300"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Retry
|
||||
<Button onClick={() => window.location.reload()}>
|
||||
Refresh Page
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="h-screen flex overflow-hidden flex-col">
|
||||
{/* Auto save loading indicator */}
|
||||
{autoSaveLoading && (
|
||||
<div className="fixed right-6 top-[5.2rem] z-50 bg-white bg-opacity-50 flex items-center justify-center">
|
||||
<Loader2 className="animate-spin text-primary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="fixed right-6 top-[5.2rem] z-50">
|
||||
{isSaving && (
|
||||
<Loader2 className="w-6 h-6 animate-spin text-blue-500" />
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
<Header presentation_id={presentation_id} currentSlide={currentSlide} />
|
||||
<Help />
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ const SlideContent = ({
|
|||
{isStreaming && (
|
||||
<Loader2 className="w-8 h-8 absolute right-2 top-2 z-30 text-blue-800 animate-spin" />
|
||||
)}
|
||||
<div className={` w-full group mb-6`}>
|
||||
<div data-layout={slide.layout} data-group={slide.layout_group} className={` w-full group mb-6`}>
|
||||
{/* render slides */}
|
||||
{loading ? <div className="flex flex-col bg-white aspect-video items-center justify-center h-full">
|
||||
<Loader2 className="w-8 h-8 animate-spin" />
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export { usePresentationStreaming } from './usePresentationStreaming';
|
||||
export { usePresentationData } from './usePresentationData';
|
||||
export { usePresentationNavigation } from './usePresentationNavigation';
|
||||
export { usePresentationNavigation } from './usePresentationNavigation';
|
||||
export { useAutoSave } from './useAutoSave';
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
'use client'
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { RootState } from '@/store/store';
|
||||
import { PresentationGenerationApi } from '../../services/api/presentation-generation';
|
||||
|
||||
interface UseAutoSaveOptions {
|
||||
debounceMs?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export const useAutoSave = ({
|
||||
debounceMs = 2000,
|
||||
enabled = true,
|
||||
}: UseAutoSaveOptions = {}) => {
|
||||
const { presentationData } = useSelector(
|
||||
(state: RootState) => state.presentationGeneration
|
||||
);
|
||||
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastSavedDataRef = useRef<string>('');
|
||||
const [isSaving, setIsSaving] = useState<boolean>(false);
|
||||
|
||||
// Debounced save function
|
||||
const debouncedSave = useCallback(async (data: any) => {
|
||||
// Clear existing timeout
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Set new timeout
|
||||
saveTimeoutRef.current = setTimeout(async () => {
|
||||
if (!data || isSaving) return;
|
||||
|
||||
const currentDataString = JSON.stringify(data);
|
||||
|
||||
// Skip if data hasn't changed since last save
|
||||
if (currentDataString === lastSavedDataRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
console.log('🔄 Auto-saving presentation data...');
|
||||
|
||||
// Call the API to update presentation content
|
||||
await PresentationGenerationApi.updatePresentationContent(data);
|
||||
|
||||
// Update last saved data reference
|
||||
lastSavedDataRef.current = currentDataString;
|
||||
|
||||
console.log('✅ Auto-save successful');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Auto-save failed:', error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, debounceMs);
|
||||
}, [debounceMs, isSaving]);
|
||||
|
||||
// Effect to trigger auto-save when presentation data changes
|
||||
useEffect(() => {
|
||||
if (!enabled || !presentationData) return;
|
||||
|
||||
// Trigger debounced save
|
||||
debouncedSave(presentationData);
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
return () => {
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [presentationData, enabled, debouncedSave]);
|
||||
|
||||
return {
|
||||
isSaving,
|
||||
};
|
||||
};
|
||||
|
|
@ -16,7 +16,6 @@ export const usePresentationData = (
|
|||
const fetchUserSlides = useCallback(async () => {
|
||||
try {
|
||||
const data = await DashboardApi.getPresentation(presentationId);
|
||||
console.log('Presentation Data',data);
|
||||
if (data) {
|
||||
dispatch(setPresentationData(data));
|
||||
setLoading(false);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import { setPresentationData, setStreaming } from "@/store/slices/presentationGeneration";
|
||||
import { jsonrepair } from "jsonrepair";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
import { useCallback, useRef } from "react";
|
||||
|
||||
export function useDebounce<T extends (...args: any[]) => void>(
|
||||
callback: T,
|
||||
delay: number
|
||||
) {
|
||||
const timeoutRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
return useCallback(
|
||||
(...args: Parameters<T>) => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
callback(...args);
|
||||
}, delay);
|
||||
},
|
||||
[callback, delay]
|
||||
);
|
||||
}
|
||||
|
|
@ -3,25 +3,6 @@ import { IconSearch, ImageGenerate, ImageSearch } from "./params";
|
|||
|
||||
export class PresentationGenerationApi {
|
||||
|
||||
static async getChapterDetails() {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/ppt/chapter-details`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: getHeader(),
|
||||
cache: "no-cache",
|
||||
}
|
||||
);
|
||||
if (response.status === 200) {
|
||||
const data = await response.json();
|
||||
return data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error getting chapter details:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async uploadDoc(documents: File[]) {
|
||||
const formData = new FormData();
|
||||
|
|
@ -80,62 +61,9 @@ export class PresentationGenerationApi {
|
|||
throw error;
|
||||
}
|
||||
}
|
||||
static async titleGeneration({
|
||||
presentation_id,
|
||||
}: {
|
||||
presentation_id: string;
|
||||
}) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/ppt/presentation/outlines/generate`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: getHeader(),
|
||||
body: JSON.stringify({
|
||||
prompt: prompt,
|
||||
presentation_id: presentation_id,
|
||||
}),
|
||||
cache: "no-cache",
|
||||
}
|
||||
);
|
||||
if (response.status === 200) {
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
return data;
|
||||
} else {
|
||||
throw new Error(`Failed to generate titles: ${response.statusText}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("error in title generation", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async generatePresentation(presentationData: any) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/ppt/generate`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: getHeader(),
|
||||
body: JSON.stringify(presentationData),
|
||||
cache: "no-cache",
|
||||
}
|
||||
);
|
||||
if (response.status === 200) {
|
||||
const data = await response.json();
|
||||
|
||||
return data;
|
||||
} else {
|
||||
throw new Error(
|
||||
`Failed to generate presentation: ${response.statusText}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("error in presentation generation", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async editSlide(
|
||||
presentation_id: string,
|
||||
index: number,
|
||||
|
|
@ -172,9 +100,9 @@ export class PresentationGenerationApi {
|
|||
static async updatePresentationContent(body: any) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/ppt/slides/update`,
|
||||
`/api/v1/ppt/presentation/update`,
|
||||
{
|
||||
method: "POST",
|
||||
method: "PUT",
|
||||
headers: getHeader(),
|
||||
body: JSON.stringify(body),
|
||||
cache: "no-cache",
|
||||
|
|
@ -375,33 +303,7 @@ export class PresentationGenerationApi {
|
|||
throw error;
|
||||
}
|
||||
}
|
||||
// SET THEME COLORS
|
||||
static async setThemeColors(presentation_id: string, theme: any) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/ppt/presentation/theme`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: getHeader(),
|
||||
body: JSON.stringify({
|
||||
presentation_id,
|
||||
theme,
|
||||
}),
|
||||
|
||||
}
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} else {
|
||||
throw new Error(`Failed to set theme colors: ${response.statusText}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("error in theme colors set", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// QUESTIONS
|
||||
|
||||
|
||||
static async createPresentation({
|
||||
prompt,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { ApiError } from "@/models/errors";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import puppeteer, { ElementHandle } from "puppeteer";
|
||||
import { ElementAttributes } from "@/types/element_attibutes";
|
||||
import { ElementAttributes, SlideAttributesResult } from "@/types/element_attibutes";
|
||||
import { convertElementAttributesToPptxSlides } from "@/utils/pptx_models_utils";
|
||||
import { PptxPresentationModel } from "@/types/pptx_models";
|
||||
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
|
|
@ -9,14 +11,12 @@ export async function GET(request: NextRequest) {
|
|||
try {
|
||||
const id = await getPresentationId(request);
|
||||
const slides = await getSlides(id);
|
||||
const slide = slides[0];
|
||||
const attributes = await getAllChildElementsAttributes(slide);
|
||||
console.log(attributes);
|
||||
|
||||
// Temporary
|
||||
return NextResponse.json({
|
||||
attributes: attributes,
|
||||
});
|
||||
const slides_attributes = await getSlidesAttributes(slides);
|
||||
const slides_pptx_models = convertElementAttributesToPptxSlides(slides_attributes.elements, slides_attributes.backgroundColors);
|
||||
const presentation_pptx_model: PptxPresentationModel = {
|
||||
slides: slides_pptx_models,
|
||||
};
|
||||
return NextResponse.json(presentation_pptx_model);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
if (error instanceof ApiError) {
|
||||
|
|
@ -34,6 +34,38 @@ async function getPresentationId(request: NextRequest) {
|
|||
return id;
|
||||
}
|
||||
|
||||
async function getSlidesAttributes(slides: ElementHandle<Element>[]) {
|
||||
const slideResults = await Promise.all(slides.map(async (slide) => {
|
||||
return await getAllChildElementsAttributes(slide);
|
||||
}));
|
||||
|
||||
// Extract elements and background colors from each slide result
|
||||
const elements = slideResults.map(result => result.elements);
|
||||
const backgroundColors = slideResults.map(result => result.backgroundColor);
|
||||
|
||||
return {
|
||||
elements,
|
||||
backgroundColors
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
async function getSlides(id: string) {
|
||||
const slides_wrapper = await getSlidesWrapper(id);
|
||||
const slides = await slides_wrapper.$$(":scope > div > div");
|
||||
return slides;
|
||||
}
|
||||
|
||||
async function getSlidesWrapper(id: string): Promise<ElementHandle<Element>> {
|
||||
const page = await getPresentationPage(id);
|
||||
const slides_wrapper = await page.$("#presentation-slides-wrapper");
|
||||
if (!slides_wrapper) {
|
||||
throw new ApiError("Presentation slides not found");
|
||||
}
|
||||
return slides_wrapper;
|
||||
}
|
||||
|
||||
|
||||
async function getPresentationPage(id: string) {
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
|
|
@ -48,20 +80,111 @@ async function getPresentationPage(id: string) {
|
|||
return page;
|
||||
}
|
||||
|
||||
async function getSlidesWrapper(id: string): Promise<ElementHandle<Element>> {
|
||||
const page = await getPresentationPage(id);
|
||||
const slides_wrapper = await page.$("#presentation-slides-wrapper");
|
||||
if (!slides_wrapper) {
|
||||
throw new ApiError("Presentation slides not found");
|
||||
|
||||
async function getAllChildElementsAttributes(element: ElementHandle<Element>): Promise<SlideAttributesResult> {
|
||||
// Get the root element's bounding rect for relative positioning
|
||||
const rootRect = await element.evaluate((el) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
return {
|
||||
left: isFinite(rect.left) ? rect.left : 0,
|
||||
top: isFinite(rect.top) ? rect.top : 0,
|
||||
width: isFinite(rect.width) ? rect.width : 0,
|
||||
height: isFinite(rect.height) ? rect.height : 0,
|
||||
};
|
||||
});
|
||||
|
||||
// Get all child elements as ElementHandles
|
||||
const childElementHandles = await element.$$(':scope *');
|
||||
|
||||
// Get attributes and depth for each child element
|
||||
const attributesPromises = childElementHandles.map(async (childElementHandle) => {
|
||||
const attributes = await getElementAttributes(childElementHandle);
|
||||
|
||||
// Calculate the depth of the element in the DOM tree
|
||||
const depth = await childElementHandle.evaluate((el) => {
|
||||
let depth = 0;
|
||||
let current = el;
|
||||
while (current.parentElement) {
|
||||
depth++;
|
||||
current = current.parentElement;
|
||||
}
|
||||
return depth;
|
||||
});
|
||||
|
||||
// Convert positions to relative positions
|
||||
if (attributes.position && attributes.position.left !== undefined && attributes.position.top !== undefined) {
|
||||
attributes.position = {
|
||||
left: attributes.position.left - rootRect.left,
|
||||
top: attributes.position.top - rootRect.top,
|
||||
width: attributes.position.width,
|
||||
height: attributes.position.height,
|
||||
};
|
||||
}
|
||||
|
||||
return { attributes, depth };
|
||||
});
|
||||
|
||||
const allResults = await Promise.all(attributesPromises);
|
||||
|
||||
// Extract background color from elements whose position is the same as root element
|
||||
let backgroundColor: string | undefined;
|
||||
const elementsWithRootPosition = allResults.filter(({ attributes }) => {
|
||||
return attributes.position &&
|
||||
attributes.position.left === 0 &&
|
||||
attributes.position.top === 0 &&
|
||||
attributes.position.width === rootRect.width &&
|
||||
attributes.position.height === rootRect.height;
|
||||
});
|
||||
|
||||
// Get the background color from the first element with root position that has a background
|
||||
for (const { attributes } of elementsWithRootPosition) {
|
||||
if (attributes.background && attributes.background.color) {
|
||||
backgroundColor = attributes.background.color;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return slides_wrapper;
|
||||
|
||||
// Filter out elements with no meaningful styling and elements with same position as root
|
||||
const filteredResults = allResults.filter(({ attributes }) => {
|
||||
// Check if element has any meaningful styling or content
|
||||
const hasBackground = attributes.background && attributes.background.color;
|
||||
const hasBorder = attributes.border && attributes.border.color;
|
||||
const hasShadow = attributes.shadow && attributes.shadow.color;
|
||||
const hasText = attributes.innerText && attributes.innerText.trim().length > 0;
|
||||
|
||||
// Check if element position is the same as root (exclude these elements)
|
||||
const isRootPosition = attributes.position &&
|
||||
attributes.position.left === 0 &&
|
||||
attributes.position.top === 0 &&
|
||||
attributes.position.width === rootRect.width &&
|
||||
attributes.position.height === rootRect.height;
|
||||
|
||||
// Return true if element has at least one of these properties AND is not at root position
|
||||
return (hasBackground || hasBorder || hasShadow || hasText) && !isRootPosition;
|
||||
});
|
||||
|
||||
// Sort elements by z-index first, then by depth if z-index is not provided
|
||||
const sortedElements = filteredResults
|
||||
.sort((a, b) => {
|
||||
const zIndexA = a.attributes.zIndex || 0;
|
||||
const zIndexB = b.attributes.zIndex || 0;
|
||||
|
||||
// If both elements have the same z-index (including 0), sort by depth
|
||||
if (zIndexA === zIndexB) {
|
||||
return b.depth - a.depth; // Higher depth first (children before parents)
|
||||
}
|
||||
|
||||
// Otherwise sort by z-index (higher z-index first, as elements below come first)
|
||||
return zIndexB - zIndexA;
|
||||
})
|
||||
.map(({ attributes }) => attributes); // Extract just the attributes
|
||||
|
||||
return {
|
||||
elements: sortedElements,
|
||||
backgroundColor
|
||||
};
|
||||
}
|
||||
|
||||
async function getSlides(id: string) {
|
||||
const slides_wrapper = await getSlidesWrapper(id);
|
||||
const slides = await slides_wrapper.$$(":scope > div > div");
|
||||
return slides;
|
||||
}
|
||||
|
||||
async function getElementAttributes(element: ElementHandle<Element>): Promise<ElementAttributes> {
|
||||
const attributes = await element.evaluate((el) => {
|
||||
|
|
@ -80,15 +203,28 @@ async function getElementAttributes(element: ElementHandle<Element>): Promise<El
|
|||
return ctx.fillStyle;
|
||||
}
|
||||
|
||||
// Helper function to check if element has only text nodes as direct children
|
||||
function hasOnlyTextNodes(el: Element): boolean {
|
||||
const children = el.childNodes;
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const child = children[i];
|
||||
// If any child is an element node (not a text node), return false
|
||||
if (child.nodeType === Node.ELEMENT_NODE) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const computedStyles = window.getComputedStyle(el);
|
||||
|
||||
// Parse position and dimensions
|
||||
const rect = el.getBoundingClientRect();
|
||||
const position = {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
left: isFinite(rect.left) ? rect.left : 0,
|
||||
top: isFinite(rect.top) ? rect.top : 0,
|
||||
width: isFinite(rect.width) ? rect.width : 0,
|
||||
height: isFinite(rect.height) ? rect.height : 0,
|
||||
};
|
||||
|
||||
// Parse background
|
||||
|
|
@ -113,6 +249,8 @@ async function getElementAttributes(element: ElementHandle<Element>): Promise<El
|
|||
offset: undefined as [number, number] | undefined,
|
||||
color: undefined as string | undefined,
|
||||
opacity: undefined as number | undefined,
|
||||
radius: undefined as number | undefined,
|
||||
angle: undefined as number | undefined,
|
||||
};
|
||||
|
||||
if (boxShadow && boxShadow !== 'none') {
|
||||
|
|
@ -120,10 +258,13 @@ async function getElementAttributes(element: ElementHandle<Element>): Promise<El
|
|||
if (shadowParts.length >= 4) {
|
||||
const offsetX = parseFloat(shadowParts[0]);
|
||||
const offsetY = parseFloat(shadowParts[1]);
|
||||
const blurRadius = parseFloat(shadowParts[2]);
|
||||
shadow = {
|
||||
offset: (!isNaN(offsetX) && !isNaN(offsetY)) ? [offsetX, offsetY] as [number, number] : undefined,
|
||||
color: colorToHex(shadowParts[3]),
|
||||
opacity: 1,
|
||||
radius: !isNaN(blurRadius) ? blurRadius : undefined,
|
||||
angle: !isNaN(offsetX) && !isNaN(offsetY) ? Math.atan2(offsetY, offsetX) * (180 / Math.PI) : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -132,10 +273,22 @@ async function getElementAttributes(element: ElementHandle<Element>): Promise<El
|
|||
const fontSize = parseFloat(computedStyles.fontSize);
|
||||
const fontWeight = parseInt(computedStyles.fontWeight);
|
||||
const fontColor = colorToHex(computedStyles.color);
|
||||
const fontFamily = computedStyles.fontFamily;
|
||||
const fontStyle = computedStyles.fontStyle;
|
||||
|
||||
// Extract only the first font from font-family (e.g., "Hack, sans-serif" -> "Hack")
|
||||
let fontName = undefined;
|
||||
if (fontFamily !== 'initial') {
|
||||
const firstFont = fontFamily.split(',')[0].trim().replace(/['"]/g, '');
|
||||
fontName = firstFont;
|
||||
}
|
||||
|
||||
const font = {
|
||||
name: fontName,
|
||||
size: isNaN(fontSize) ? undefined : fontSize,
|
||||
weight: isNaN(fontWeight) ? undefined : fontWeight,
|
||||
color: fontColor,
|
||||
italic: fontStyle === 'italic',
|
||||
};
|
||||
|
||||
// Parse margin
|
||||
|
|
@ -143,30 +296,73 @@ async function getElementAttributes(element: ElementHandle<Element>): Promise<El
|
|||
const marginBottom = parseFloat(computedStyles.marginBottom);
|
||||
const marginLeft = parseFloat(computedStyles.marginLeft);
|
||||
const marginRight = parseFloat(computedStyles.marginRight);
|
||||
const margin = {
|
||||
const marginObj = {
|
||||
top: isNaN(marginTop) ? undefined : marginTop,
|
||||
bottom: isNaN(marginBottom) ? undefined : marginBottom,
|
||||
left: isNaN(marginLeft) ? undefined : marginLeft,
|
||||
right: isNaN(marginRight) ? undefined : marginRight,
|
||||
};
|
||||
|
||||
// Set margin as undefined if all fields are 0
|
||||
const margin = (marginObj.top === 0 && marginObj.bottom === 0 && marginObj.left === 0 && marginObj.right === 0)
|
||||
? undefined
|
||||
: marginObj;
|
||||
|
||||
// Parse padding
|
||||
const paddingTop = parseFloat(computedStyles.paddingTop);
|
||||
const paddingBottom = parseFloat(computedStyles.paddingBottom);
|
||||
const paddingLeft = parseFloat(computedStyles.paddingLeft);
|
||||
const paddingRight = parseFloat(computedStyles.paddingRight);
|
||||
const padding = {
|
||||
const paddingObj = {
|
||||
top: isNaN(paddingTop) ? undefined : paddingTop,
|
||||
bottom: isNaN(paddingBottom) ? undefined : paddingBottom,
|
||||
left: isNaN(paddingLeft) ? undefined : paddingLeft,
|
||||
right: isNaN(paddingRight) ? undefined : paddingRight,
|
||||
};
|
||||
|
||||
// Set padding as undefined if all fields are 0
|
||||
const padding = (paddingObj.top === 0 && paddingObj.bottom === 0 && paddingObj.left === 0 && paddingObj.right === 0)
|
||||
? undefined
|
||||
: paddingObj;
|
||||
|
||||
// Only include innerText if the element has only text nodes as direct children
|
||||
const innerText = hasOnlyTextNodes(el) ? (el.textContent || undefined) : undefined;
|
||||
|
||||
// Parse z-index
|
||||
const zIndex = parseInt(computedStyles.zIndex);
|
||||
const zIndexValue = isNaN(zIndex) ? 0 : zIndex;
|
||||
|
||||
// Parse additional attributes
|
||||
const textAlign = computedStyles.textAlign as 'left' | 'center' | 'right' | 'justify';
|
||||
const borderRadius = computedStyles.borderRadius;
|
||||
const objectFit = computedStyles.objectFit as 'contain' | 'cover' | 'fill' | undefined;
|
||||
const imageSrc = (el as HTMLImageElement).src;
|
||||
|
||||
// Parse border radius
|
||||
let borderRadiusValue: number | number[] | undefined;
|
||||
if (borderRadius && borderRadius !== '0px') {
|
||||
const radiusParts = borderRadius.split(' ').map(part => parseFloat(part));
|
||||
if (radiusParts.length === 1) {
|
||||
borderRadiusValue = radiusParts[0];
|
||||
} else if (radiusParts.length === 4) {
|
||||
borderRadiusValue = radiusParts;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine shape for images
|
||||
let shape: 'rectangle' | 'circle' | undefined;
|
||||
if (el.tagName.toLowerCase() === 'img') {
|
||||
shape = borderRadiusValue === 50 ? 'circle' : 'rectangle';
|
||||
}
|
||||
|
||||
// Check for text wrap
|
||||
const textWrap = computedStyles.whiteSpace !== 'nowrap';
|
||||
|
||||
return {
|
||||
tagName: el.tagName.toLowerCase(),
|
||||
id: el.id || undefined,
|
||||
className: el.className || undefined,
|
||||
innerText: el.textContent || undefined,
|
||||
innerText,
|
||||
background,
|
||||
border,
|
||||
shadow,
|
||||
|
|
@ -174,34 +370,17 @@ async function getElementAttributes(element: ElementHandle<Element>): Promise<El
|
|||
position,
|
||||
margin,
|
||||
padding,
|
||||
zIndex: zIndexValue,
|
||||
textAlign: textAlign !== 'left' ? textAlign : undefined,
|
||||
borderRadius: borderRadiusValue,
|
||||
imageSrc: imageSrc || undefined,
|
||||
objectFit,
|
||||
clip: false, // Default value
|
||||
overlay: undefined,
|
||||
shape,
|
||||
connectorType: undefined,
|
||||
textWrap,
|
||||
};
|
||||
});
|
||||
return attributes;
|
||||
}
|
||||
|
||||
async function getAllChildElementsAttributes(element: ElementHandle<Element>): Promise<ElementAttributes[]> {
|
||||
// Get the root element's bounding rect for relative positioning
|
||||
const rootRect = await element.evaluate((el) => el.getBoundingClientRect());
|
||||
|
||||
// Get all child elements as ElementHandles
|
||||
const childElementHandles = await element.$$(':scope *');
|
||||
|
||||
// Get attributes for each child element using getElementAttributes
|
||||
const attributesPromises = childElementHandles.map(async (childElementHandle) => {
|
||||
const attributes = await getElementAttributes(childElementHandle);
|
||||
|
||||
// Convert positions to relative positions
|
||||
if (attributes.position && attributes.position.left !== undefined && attributes.position.top !== undefined) {
|
||||
attributes.position = {
|
||||
left: attributes.position.left - rootRect.left,
|
||||
top: attributes.position.top - rootRect.top,
|
||||
width: attributes.position.width,
|
||||
height: attributes.position.height,
|
||||
};
|
||||
}
|
||||
|
||||
return attributes;
|
||||
});
|
||||
|
||||
return Promise.all(attributesPromises);
|
||||
}
|
||||
|
|
@ -1,36 +1,36 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import fs from 'fs';
|
||||
import { NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
|
||||
const userConfigPath = process.env.USER_CONFIG_PATH!;
|
||||
const canChangeKeys = process.env.CAN_CHANGE_KEYS !== 'false';
|
||||
|
||||
const canChangeKeys = process.env.CAN_CHANGE_KEYS !== "false";
|
||||
console.log("UserConfigPath:", userConfigPath);
|
||||
export async function GET() {
|
||||
if (!canChangeKeys) {
|
||||
return NextResponse.json({
|
||||
error: 'You are not allowed to access this resource',
|
||||
})
|
||||
error: "You are not allowed to access this resource",
|
||||
});
|
||||
}
|
||||
|
||||
if (!fs.existsSync(userConfigPath)) {
|
||||
return NextResponse.json({})
|
||||
return NextResponse.json({});
|
||||
}
|
||||
const configData = fs.readFileSync(userConfigPath, 'utf-8')
|
||||
return NextResponse.json(JSON.parse(configData))
|
||||
const configData = fs.readFileSync(userConfigPath, "utf-8");
|
||||
return NextResponse.json(JSON.parse(configData));
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!canChangeKeys) {
|
||||
return NextResponse.json({
|
||||
error: 'You are not allowed to access this resource',
|
||||
})
|
||||
error: "You are not allowed to access this resource",
|
||||
});
|
||||
}
|
||||
|
||||
const userConfig = await request.json()
|
||||
const userConfig = await request.json();
|
||||
|
||||
let existingConfig: LLMConfig = {}
|
||||
let existingConfig: LLMConfig = {};
|
||||
if (fs.existsSync(userConfigPath)) {
|
||||
const configData = fs.readFileSync(userConfigPath, 'utf-8')
|
||||
existingConfig = JSON.parse(configData)
|
||||
const configData = fs.readFileSync(userConfigPath, "utf-8");
|
||||
existingConfig = JSON.parse(configData);
|
||||
}
|
||||
const mergedConfig: LLMConfig = {
|
||||
LLM: userConfig.LLM || existingConfig.LLM,
|
||||
|
|
@ -39,11 +39,18 @@ export async function POST(request: Request) {
|
|||
OLLAMA_URL: userConfig.OLLAMA_URL || existingConfig.OLLAMA_URL,
|
||||
OLLAMA_MODEL: userConfig.OLLAMA_MODEL || existingConfig.OLLAMA_MODEL,
|
||||
CUSTOM_LLM_URL: userConfig.CUSTOM_LLM_URL || existingConfig.CUSTOM_LLM_URL,
|
||||
CUSTOM_LLM_API_KEY: userConfig.CUSTOM_LLM_API_KEY || existingConfig.CUSTOM_LLM_API_KEY,
|
||||
CUSTOM_LLM_API_KEY:
|
||||
userConfig.CUSTOM_LLM_API_KEY || existingConfig.CUSTOM_LLM_API_KEY,
|
||||
CUSTOM_MODEL: userConfig.CUSTOM_MODEL || existingConfig.CUSTOM_MODEL,
|
||||
PIXABAY_API_KEY:
|
||||
userConfig.PIXABAY_API_KEY || existingConfig.PIXABAY_API_KEY,
|
||||
IMAGE_PROVIDER: userConfig.IMAGE_PROVIDER || existingConfig.IMAGE_PROVIDER,
|
||||
PEXELS_API_KEY: userConfig.PEXELS_API_KEY || existingConfig.PEXELS_API_KEY,
|
||||
USE_CUSTOM_URL: userConfig.USE_CUSTOM_URL === undefined ? existingConfig.USE_CUSTOM_URL : userConfig.USE_CUSTOM_URL,
|
||||
}
|
||||
fs.writeFileSync(userConfigPath, JSON.stringify(mergedConfig))
|
||||
return NextResponse.json(mergedConfig)
|
||||
}
|
||||
USE_CUSTOM_URL:
|
||||
userConfig.USE_CUSTOM_URL === undefined
|
||||
? existingConfig.USE_CUSTOM_URL
|
||||
: userConfig.USE_CUSTOM_URL,
|
||||
};
|
||||
fs.writeFileSync(userConfigPath, JSON.stringify(mergedConfig));
|
||||
return NextResponse.json(mergedConfig);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ export const PresentationCard = ({
|
|||
>
|
||||
<div className="absolute bg-transparent z-40 top-0 left-0 w-full h-full" />
|
||||
<div className="transform scale-[0.2] flex justify-center items-center origin-top-left w-[500%] h-[500%]">
|
||||
{renderSlideContent(slide)}
|
||||
{renderSlideContent(slide, false)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -84,9 +84,9 @@ export function StoreInitializer({ children }: { children: React.ReactNode }) {
|
|||
|
||||
const checkIfSelectedOllamaModelIsPulled = async (ollamaModel: string) => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/ppt/ollama/list-pulled-models');
|
||||
const data = await response.json();
|
||||
const pulledModels = data.map((model: any) => model.name);
|
||||
const response = await fetch('/api/v1/ppt/ollama/models/available');
|
||||
const models = await response.json();
|
||||
const pulledModels = models.map((model: any) => model.name);
|
||||
return pulledModels.includes(ollamaModel);
|
||||
} catch (error) {
|
||||
console.error('Error checking if selected Ollama model is pulled:', error);
|
||||
|
|
@ -96,7 +96,7 @@ export function StoreInitializer({ children }: { children: React.ReactNode }) {
|
|||
|
||||
const checkIfSelectedCustomModelIsAvailable = async (customModel: string) => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/ppt/models/list/custom', {
|
||||
const response = await fetch('/api/v1/ppt/custom_llm/models/available', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
37
servers/nextjs/package-lock.json
generated
37
servers/nextjs/package-lock.json
generated
|
|
@ -48,6 +48,7 @@
|
|||
"puppeteer": "^24.13.0",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-element-to-jsx-string": "^15.0.0",
|
||||
"react-redux": "^9.1.2",
|
||||
"recharts": "^2.15.4",
|
||||
"sonner": "^2.0.6",
|
||||
|
|
@ -115,6 +116,12 @@
|
|||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@base2/pretty-print-object": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@base2/pretty-print-object/-/pretty-print-object-1.0.1.tgz",
|
||||
"integrity": "sha512-4iri8i1AqYHJE2DstZYkyEprg6Pq6sKx3xn5FpySk9sNhH7qN2LLlHJCfDTZRILNwQNPD7mATWM0TBui7uC1pA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@cypress/request": {
|
||||
"version": "3.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.8.tgz",
|
||||
|
|
@ -4594,6 +4601,15 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-plain-object": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
|
||||
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-stream": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
||||
|
|
@ -6055,6 +6071,27 @@
|
|||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-element-to-jsx-string": {
|
||||
"version": "15.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-element-to-jsx-string/-/react-element-to-jsx-string-15.0.0.tgz",
|
||||
"integrity": "sha512-UDg4lXB6BzlobN60P8fHWVPX3Kyw8ORrTeBtClmIlGdkOOE+GYQSFvmEU5iLLpwp/6v42DINwNcwOhOLfQ//FQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@base2/pretty-print-object": "1.0.1",
|
||||
"is-plain-object": "5.0.0",
|
||||
"react-is": "18.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^0.14.8 || ^15.0.1 || ^16.0.0 || ^17.0.1 || ^18.0.0",
|
||||
"react-dom": "^0.14.8 || ^15.0.1 || ^16.0.0 || ^17.0.1 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-element-to-jsx-string/node_modules/react-is": {
|
||||
"version": "18.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.1.0.tgz",
|
||||
"integrity": "sha512-Fl7FuabXsJnV5Q1qIOQwx/sagGF18kogb4gpfcG4gjLBWO0WDiiz1ko/ExayuxE7InyQkBLkxRFG5oxY6Uu3Kg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@
|
|||
"puppeteer": "^24.13.0",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-element-to-jsx-string": "^15.0.0",
|
||||
"react-redux": "^9.1.2",
|
||||
"recharts": "^2.15.4",
|
||||
"sonner": "^2.0.6",
|
||||
|
|
|
|||
|
|
@ -1,40 +1,14 @@
|
|||
import { Slide } from "@/app/(presentation-generator)/types/slide";
|
||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
|
||||
interface Series {
|
||||
data: number[];
|
||||
name?: string;
|
||||
}
|
||||
interface DataLabel {
|
||||
dataLabelPosition: "Outside" | "Inside";
|
||||
dataLabelAlignment: "Base" | "Center" | "End";
|
||||
}
|
||||
export interface ChartSettings {
|
||||
showLegend: boolean;
|
||||
showGrid: boolean;
|
||||
showAxisLabel: boolean;
|
||||
showDataLabel: boolean;
|
||||
dataLabel: DataLabel;
|
||||
}
|
||||
|
||||
|
||||
export interface SlideOutline {
|
||||
title?: string;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
export interface Chart {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
style: ChartSettings | {} | null;
|
||||
unit?: string | null;
|
||||
presentation: string;
|
||||
postfix: string;
|
||||
data: {
|
||||
categories: string[];
|
||||
series: Series[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface PresentationData {
|
||||
id: string;
|
||||
language: string;
|
||||
|
|
@ -50,20 +24,18 @@ export interface PresentationData {
|
|||
|
||||
interface PresentationGenerationState {
|
||||
presentation_id: string | null;
|
||||
documents: string[];
|
||||
images: string[];
|
||||
isLoading: boolean;
|
||||
isStreaming: boolean | null;
|
||||
outlines: SlideOutline[];
|
||||
error: string | null;
|
||||
presentationData: PresentationData | null;
|
||||
isSlidesRendered: boolean;
|
||||
}
|
||||
|
||||
const initialState: PresentationGenerationState = {
|
||||
presentation_id: null,
|
||||
documents: [],
|
||||
images: [],
|
||||
outlines: [],
|
||||
isSlidesRendered: false,
|
||||
isLoading: false,
|
||||
isStreaming: null,
|
||||
error: null,
|
||||
|
|
@ -86,6 +58,10 @@ const presentationGenerationSlice = createSlice({
|
|||
state.presentation_id = action.payload;
|
||||
state.error = null;
|
||||
},
|
||||
// Slides rendered
|
||||
setSlidesRendered: (state, action: PayloadAction<boolean>) => {
|
||||
state.isSlidesRendered = action.payload;
|
||||
},
|
||||
// Error
|
||||
setError: (state, action: PayloadAction<string>) => {
|
||||
state.error = action.payload;
|
||||
|
|
@ -97,14 +73,6 @@ const presentationGenerationSlice = createSlice({
|
|||
state.error = null;
|
||||
state.isLoading = false;
|
||||
},
|
||||
// Set documents
|
||||
setDocs: (state, action: PayloadAction<string[]>) => {
|
||||
state.documents = action.payload;
|
||||
},
|
||||
// Set images
|
||||
setImgs: (state, action: PayloadAction<string[]>) => {
|
||||
state.images = action.payload;
|
||||
},
|
||||
// Set outlines
|
||||
setOutlines: (state, action: PayloadAction<SlideOutline[]>) => {
|
||||
state.outlines = action.payload;
|
||||
|
|
@ -166,252 +134,201 @@ const presentationGenerationSlice = createSlice({
|
|||
action.payload.slide;
|
||||
}
|
||||
},
|
||||
updateSlideVariant: (
|
||||
|
||||
// Update slide content at specific data path (for Tiptap text editing)
|
||||
updateSlideContent: (
|
||||
state,
|
||||
action: PayloadAction<{ index: number; variant: number }>
|
||||
action: PayloadAction<{
|
||||
slideIndex: number;
|
||||
dataPath: string;
|
||||
content: string;
|
||||
}>
|
||||
) => {
|
||||
if (
|
||||
state.presentationData &&
|
||||
state.presentationData.slides[action.payload.index]
|
||||
state.presentationData.slides &&
|
||||
state.presentationData.slides[action.payload.slideIndex]
|
||||
) {
|
||||
state.presentationData.slides[action.payload.index].design_index =
|
||||
action.payload.variant;
|
||||
}
|
||||
},
|
||||
updateSlideTitle: (
|
||||
state,
|
||||
action: PayloadAction<{ index: number; title: string }>
|
||||
) => {
|
||||
if (state.presentationData?.slides[action.payload.index]) {
|
||||
state.presentationData.slides[action.payload.index].content.title =
|
||||
action.payload.title;
|
||||
}
|
||||
},
|
||||
updateSlideDescription: (
|
||||
state,
|
||||
action: PayloadAction<{ index: number; description: string }>
|
||||
) => {
|
||||
if (state.presentationData?.slides[action.payload.index]) {
|
||||
state.presentationData.slides[
|
||||
action.payload.index
|
||||
].content.description = action.payload.description;
|
||||
}
|
||||
},
|
||||
updateSlideBodyString: (
|
||||
state,
|
||||
action: PayloadAction<{ index: number; body: string }>
|
||||
) => {
|
||||
if (state.presentationData?.slides[action.payload.index]) {
|
||||
state.presentationData.slides[action.payload.index].content.body =
|
||||
action.payload.body;
|
||||
}
|
||||
},
|
||||
updateSlideBodyHeading: (
|
||||
state,
|
||||
action: PayloadAction<{ index: number; bodyIdx: number; heading: string }>
|
||||
) => {
|
||||
if (state.presentationData?.slides[action.payload.index]) {
|
||||
state.presentationData.slides[action.payload.index].content.body[
|
||||
action.payload.bodyIdx
|
||||
// @ts-ignore
|
||||
].heading = action.payload.heading;
|
||||
}
|
||||
},
|
||||
updateSlideBodyDescription: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
index: number;
|
||||
bodyIdx: number;
|
||||
description: string;
|
||||
}>
|
||||
) => {
|
||||
if (state.presentationData?.slides[action.payload.index]) {
|
||||
state.presentationData.slides[action.payload.index].content.body[
|
||||
action.payload.bodyIdx
|
||||
// @ts-ignore
|
||||
].description = action.payload.description;
|
||||
}
|
||||
},
|
||||
updateSlideImage: (
|
||||
state,
|
||||
action: PayloadAction<{ index: number; imageIdx: number; image: string }>
|
||||
) => {
|
||||
if (state.presentationData?.slides[action.payload.index]?.images) {
|
||||
state.presentationData.slides[action.payload.index].images![
|
||||
action.payload.imageIdx
|
||||
] = action.payload.image;
|
||||
}
|
||||
},
|
||||
updateSlideIcon: (
|
||||
state,
|
||||
action: PayloadAction<{ index: number; iconIdx: number; icon: string }>
|
||||
) => {
|
||||
if (state.presentationData?.slides[action.payload.index]?.icons) {
|
||||
state.presentationData.slides[action.payload.index].icons![
|
||||
action.payload.iconIdx
|
||||
] = action.payload.icon;
|
||||
}
|
||||
},
|
||||
updateSlideChart: (
|
||||
state,
|
||||
action: PayloadAction<{ index: number; chart: Chart }>
|
||||
) => {
|
||||
if (state.presentationData?.slides[action.payload.index]) {
|
||||
state.presentationData.slides[action.payload.index].content.graph =
|
||||
action.payload.chart;
|
||||
}
|
||||
},
|
||||
updateSlideChartSettings: (
|
||||
state,
|
||||
action: PayloadAction<{ index: number; chartSettings: ChartSettings }>
|
||||
) => {
|
||||
if (state.presentationData?.slides[action.payload.index]) {
|
||||
const defaultSettings: ChartSettings = {
|
||||
showLegend: false,
|
||||
showGrid: false,
|
||||
showAxisLabel: true,
|
||||
showDataLabel: true,
|
||||
dataLabel: {
|
||||
dataLabelPosition: "Outside",
|
||||
dataLabelAlignment: "Center",
|
||||
},
|
||||
};
|
||||
state.presentationData.slides[
|
||||
action.payload.index
|
||||
].content.graph.style = {
|
||||
...defaultSettings,
|
||||
...action.payload.chartSettings,
|
||||
const slide = state.presentationData.slides[action.payload.slideIndex];
|
||||
const { dataPath, content } = action.payload;
|
||||
|
||||
// Helper function to set nested property value
|
||||
const setNestedValue = (obj: any, path: string, value: string) => {
|
||||
const keys = path.split(/[.\[\]]+/).filter(Boolean);
|
||||
let current = obj;
|
||||
|
||||
// Navigate to the parent object
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
const key = keys[i];
|
||||
if (isNaN(Number(key))) {
|
||||
// String key
|
||||
if (!current[key]) {
|
||||
current[key] = {};
|
||||
}
|
||||
current = current[key];
|
||||
} else {
|
||||
// Array index
|
||||
const index = Number(key);
|
||||
if (!current[index]) {
|
||||
current[index] = {};
|
||||
}
|
||||
current = current[index];
|
||||
}
|
||||
}
|
||||
|
||||
// Set the final value
|
||||
const finalKey = keys[keys.length - 1];
|
||||
if (isNaN(Number(finalKey))) {
|
||||
current[finalKey] = value;
|
||||
} else {
|
||||
current[Number(finalKey)] = value;
|
||||
}
|
||||
};
|
||||
|
||||
// Update the slide content
|
||||
if (dataPath && slide.content) {
|
||||
setNestedValue(slide.content, dataPath, content);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
addSlideBodyItem: (
|
||||
// Update slide image at specific data path
|
||||
updateSlideImage: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
index: number;
|
||||
item: { heading: string; description: string };
|
||||
slideIndex: number;
|
||||
dataPath: string;
|
||||
imageUrl: string;
|
||||
prompt?: string;
|
||||
}>
|
||||
) => {
|
||||
if (state.presentationData?.slides[action.payload.index]?.content.body) {
|
||||
// @ts-ignore
|
||||
state.presentationData.slides[action.payload.index].content.body.push(
|
||||
action.payload.item
|
||||
);
|
||||
}
|
||||
},
|
||||
addSlideImage: (
|
||||
state,
|
||||
action: PayloadAction<{ index: number; image: string }>
|
||||
) => {
|
||||
if (state.presentationData?.slides[action.payload.index]?.images) {
|
||||
state.presentationData.slides[action.payload.index].images!.push(
|
||||
action.payload.image
|
||||
);
|
||||
}
|
||||
},
|
||||
deleteSlideImage: (
|
||||
state,
|
||||
action: PayloadAction<{ index: number; imageIdx: number }>
|
||||
) => {
|
||||
if (state.presentationData?.slides[action.payload.index]?.images) {
|
||||
state.presentationData.slides[action.payload.index].images!.splice(
|
||||
action.payload.imageIdx,
|
||||
1
|
||||
);
|
||||
}
|
||||
},
|
||||
updateSlideProperties: (
|
||||
state,
|
||||
action: PayloadAction<{ index: number; itemIdx: number; properties: any }>
|
||||
) => {
|
||||
if (state.presentationData?.slides[action.payload.index]) {
|
||||
// Initialize properties object if it doesn't exist
|
||||
if (!state.presentationData.slides[action.payload.index].properties) {
|
||||
state.presentationData.slides[action.payload.index].properties = {};
|
||||
if (
|
||||
state.presentationData &&
|
||||
state.presentationData.slides &&
|
||||
state.presentationData.slides[action.payload.slideIndex]
|
||||
) {
|
||||
const slide = state.presentationData.slides[action.payload.slideIndex];
|
||||
const { dataPath, imageUrl, prompt } = action.payload;
|
||||
|
||||
// Helper function to set nested property value for images
|
||||
const setNestedImageValue = (obj: any, path: string, url: string, promptText?: string) => {
|
||||
const keys = path.split(/[.\[\]]+/).filter(Boolean);
|
||||
let current = obj;
|
||||
|
||||
// Navigate to the parent object
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
const key = keys[i];
|
||||
if (isNaN(Number(key))) {
|
||||
if (!current[key]) {
|
||||
current[key] = {};
|
||||
}
|
||||
current = current[key];
|
||||
} else {
|
||||
const index = Number(key);
|
||||
if (!current[index]) {
|
||||
current[index] = {};
|
||||
}
|
||||
current = current[index];
|
||||
}
|
||||
}
|
||||
|
||||
// Set the image properties
|
||||
const finalKey = keys[keys.length - 1];
|
||||
if (isNaN(Number(finalKey))) {
|
||||
current[finalKey] = {
|
||||
__image_url__: url,
|
||||
__image_prompt__: promptText || ''
|
||||
};
|
||||
} else {
|
||||
current[Number(finalKey)] = {
|
||||
__image_url__: url,
|
||||
__image_prompt__: promptText || ''
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Update the slide image
|
||||
if (dataPath && slide.content) {
|
||||
setNestedImageValue(slide.content, dataPath, imageUrl, prompt);
|
||||
}
|
||||
|
||||
// Also update the images array if it exists
|
||||
if (slide.images && Array.isArray(slide.images)) {
|
||||
const imageIndex = parseInt(dataPath.split('[')[1]?.split(']')[0]) || 0;
|
||||
if (slide.images[imageIndex] !== undefined) {
|
||||
slide.images[imageIndex] = imageUrl;
|
||||
}
|
||||
}
|
||||
// Assign the properties to the specific item index
|
||||
state.presentationData.slides[action.payload.index].properties[
|
||||
action.payload.itemIdx
|
||||
] = action.payload.properties;
|
||||
}
|
||||
},
|
||||
// Infographics
|
||||
addInfographics: (
|
||||
state,
|
||||
action: PayloadAction<{ slideIndex: number; item: any }>
|
||||
) => {
|
||||
if (state.presentationData?.slides[action.payload.slideIndex]?.content) {
|
||||
// @ts-ignore
|
||||
state.presentationData.slides[
|
||||
action.payload.slideIndex
|
||||
].content.infographics.push(action.payload.item);
|
||||
}
|
||||
},
|
||||
deleteInfographics: (
|
||||
state,
|
||||
action: PayloadAction<{ slideIndex: number; itemIdx: number }>
|
||||
) => {
|
||||
if (state.presentationData?.slides[action.payload.slideIndex]?.content) {
|
||||
// @ts-ignore
|
||||
state.presentationData.slides[
|
||||
action.payload.slideIndex
|
||||
].content.infographics.splice(action.payload.itemIdx, 1);
|
||||
}
|
||||
},
|
||||
updateInfographicsTitle: (
|
||||
|
||||
// Update slide icon at specific data path
|
||||
updateSlideIcon: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
slideIndex: number;
|
||||
itemIdx: number;
|
||||
title: string;
|
||||
dataPath: string;
|
||||
iconUrl: string;
|
||||
query?: string;
|
||||
}>
|
||||
) => {
|
||||
if (state.presentationData?.slides[action.payload.slideIndex]?.content) {
|
||||
// @ts-ignore
|
||||
state.presentationData.slides[
|
||||
action.payload.slideIndex
|
||||
].content.infographics[action.payload.itemIdx].title =
|
||||
action.payload.title;
|
||||
}
|
||||
},
|
||||
updateInfographicsDescription: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
slideIndex: number;
|
||||
itemIdx: number;
|
||||
description: string;
|
||||
}>
|
||||
) => {
|
||||
if (state.presentationData?.slides[action.payload.slideIndex]?.content) {
|
||||
// @ts-ignore
|
||||
state.presentationData.slides[
|
||||
action.payload.slideIndex
|
||||
].content.infographics[action.payload.itemIdx].description =
|
||||
action.payload.description;
|
||||
}
|
||||
},
|
||||
updateInfographicsChart: (
|
||||
state,
|
||||
action: PayloadAction<{ slideIndex: number; itemIdx: number; chart: any }>
|
||||
) => {
|
||||
if (state.presentationData?.slides[action.payload.slideIndex]?.content) {
|
||||
// @ts-ignore
|
||||
state.presentationData.slides[
|
||||
action.payload.slideIndex
|
||||
].content.infographics[action.payload.itemIdx].chart =
|
||||
action.payload.chart;
|
||||
}
|
||||
},
|
||||
deleteSlideBodyItem: (
|
||||
state,
|
||||
action: PayloadAction<{ index: number; itemIdx: number }>
|
||||
) => {
|
||||
if (state.presentationData?.slides[action.payload.index]?.content.body) {
|
||||
// @ts-ignore
|
||||
state.presentationData.slides[action.payload.index].content.body.splice(
|
||||
action.payload.itemIdx,
|
||||
1
|
||||
);
|
||||
if (
|
||||
state.presentationData &&
|
||||
state.presentationData.slides &&
|
||||
state.presentationData.slides[action.payload.slideIndex]
|
||||
) {
|
||||
const slide = state.presentationData.slides[action.payload.slideIndex];
|
||||
const { dataPath, iconUrl, query } = action.payload;
|
||||
|
||||
// Helper function to set nested property value for icons
|
||||
const setNestedIconValue = (obj: any, path: string, url: string, queryText?: string) => {
|
||||
const keys = path.split(/[.\[\]]+/).filter(Boolean);
|
||||
let current = obj;
|
||||
|
||||
// Navigate to the parent object
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
const key = keys[i];
|
||||
if (isNaN(Number(key))) {
|
||||
if (!current[key]) {
|
||||
current[key] = {};
|
||||
}
|
||||
current = current[key];
|
||||
} else {
|
||||
const index = Number(key);
|
||||
if (!current[index]) {
|
||||
current[index] = {};
|
||||
}
|
||||
current = current[index];
|
||||
}
|
||||
}
|
||||
|
||||
// Set the icon properties
|
||||
const finalKey = keys[keys.length - 1];
|
||||
if (isNaN(Number(finalKey))) {
|
||||
current[finalKey] = {
|
||||
__icon_url__: url,
|
||||
__icon_query__: queryText || ''
|
||||
};
|
||||
} else {
|
||||
current[Number(finalKey)] = {
|
||||
__icon_url__: url,
|
||||
__icon_query__: queryText || ''
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Update the slide icon
|
||||
if (dataPath && slide.content) {
|
||||
setNestedIconValue(slide.content, dataPath, iconUrl, query);
|
||||
}
|
||||
|
||||
// Also update the icons array if it exists
|
||||
if (slide.icons && Array.isArray(slide.icons)) {
|
||||
const iconIndex = parseInt(dataPath.split('[')[1]?.split(']')[0]) || 0;
|
||||
if (slide.icons[iconIndex] !== undefined) {
|
||||
slide.icons[iconIndex] = iconUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
@ -421,39 +338,19 @@ export const {
|
|||
setStreaming,
|
||||
setLoading,
|
||||
setPresentationId,
|
||||
setSlidesRendered,
|
||||
setError,
|
||||
clearPresentationData,
|
||||
setDocs,
|
||||
setImgs,
|
||||
|
||||
deleteSlideOutline,
|
||||
setPresentationData,
|
||||
setOutlines,
|
||||
// slides operations
|
||||
addSlide,
|
||||
updateSlide,
|
||||
updateSlideVariant,
|
||||
updateSlideChart,
|
||||
updateSlideChartSettings,
|
||||
updateSlideTitle,
|
||||
updateSlideDescription,
|
||||
updateSlideBodyString,
|
||||
updateSlideBodyHeading,
|
||||
updateSlideBodyDescription,
|
||||
deletePresentationSlide,
|
||||
updateSlideContent,
|
||||
updateSlideImage,
|
||||
updateSlideIcon,
|
||||
deletePresentationSlide,
|
||||
addSlideBodyItem,
|
||||
addSlideImage,
|
||||
deleteSlideImage,
|
||||
deleteSlideBodyItem,
|
||||
updateSlideProperties,
|
||||
// infographics
|
||||
addInfographics,
|
||||
deleteInfographics,
|
||||
updateInfographicsTitle,
|
||||
updateInfographicsDescription,
|
||||
updateInfographicsChart,
|
||||
} = presentationGenerationSlice.actions;
|
||||
|
||||
export default presentationGenerationSlice.reducer;
|
||||
|
|
|
|||
|
|
@ -15,11 +15,15 @@ export interface ElementAttributes {
|
|||
offset?: [number, number];
|
||||
color?: string;
|
||||
opacity?: number;
|
||||
radius?: number;
|
||||
angle?: number;
|
||||
},
|
||||
font?: {
|
||||
name?: string;
|
||||
size?: number;
|
||||
weight?: number;
|
||||
color?: string;
|
||||
italic?: boolean;
|
||||
};
|
||||
position?: {
|
||||
left?: number;
|
||||
|
|
@ -39,4 +43,19 @@ export interface ElementAttributes {
|
|||
left?: number;
|
||||
right?: number;
|
||||
};
|
||||
zIndex?: number;
|
||||
textAlign?: 'left' | 'center' | 'right' | 'justify';
|
||||
borderRadius?: number | number[];
|
||||
imageSrc?: string;
|
||||
objectFit?: 'contain' | 'cover' | 'fill';
|
||||
clip?: boolean;
|
||||
overlay?: string;
|
||||
shape?: 'rectangle' | 'circle';
|
||||
connectorType?: string;
|
||||
textWrap?: boolean;
|
||||
}
|
||||
|
||||
export interface SlideAttributesResult {
|
||||
elements: ElementAttributes[];
|
||||
backgroundColor?: string;
|
||||
}
|
||||
2
servers/nextjs/types/global.d.ts
vendored
2
servers/nextjs/types/global.d.ts
vendored
|
|
@ -22,6 +22,8 @@ interface LLMConfig {
|
|||
CUSTOM_LLM_URL?: string;
|
||||
CUSTOM_LLM_API_KEY?: string;
|
||||
CUSTOM_MODEL?: string;
|
||||
IMAGE_PROVIDER?: string;
|
||||
PIXABAY_API_KEY?: string;
|
||||
PEXELS_API_KEY?: string;
|
||||
|
||||
// Only used in UI settings
|
||||
|
|
|
|||
|
|
@ -112,12 +112,13 @@ export interface PptxConnectorModel extends PptxShapeModel {
|
|||
color?: string;
|
||||
}
|
||||
|
||||
|
||||
export interface PptxSlideModel {
|
||||
background?: PptxFillModel;
|
||||
shapes: (PptxTextBoxModel | PptxAutoShapeBoxModel | PptxConnectorModel | PptxPictureBoxModel)[];
|
||||
}
|
||||
|
||||
export interface PptxPresentationModel {
|
||||
background_color: string;
|
||||
shapes?: PptxShapeModel[];
|
||||
slides: PptxSlideModel[];
|
||||
}
|
||||
|
|
@ -145,6 +146,6 @@ export const positionToPtXyxy = (position: PptxPositionModel): number[] => {
|
|||
const top = position.top || 0;
|
||||
const width = position.width || 0;
|
||||
const height = position.height || 0;
|
||||
|
||||
|
||||
return [left, top, left + width, top + height];
|
||||
};
|
||||
|
|
|
|||
243
servers/nextjs/utils/pptx_models_utils.ts
Normal file
243
servers/nextjs/utils/pptx_models_utils.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import { ElementAttributes } from "@/types/element_attibutes";
|
||||
import {
|
||||
PptxSlideModel,
|
||||
PptxTextBoxModel,
|
||||
PptxAutoShapeBoxModel,
|
||||
PptxPictureBoxModel,
|
||||
PptxConnectorModel,
|
||||
PptxPositionModel,
|
||||
PptxSpacingModel,
|
||||
PptxFillModel,
|
||||
PptxStrokeModel,
|
||||
PptxShadowModel,
|
||||
PptxFontModel,
|
||||
PptxParagraphModel,
|
||||
PptxPictureModel,
|
||||
PptxObjectFitModel,
|
||||
PptxBoxShapeEnum,
|
||||
PptxObjectFitEnum
|
||||
} from "@/types/pptx_models";
|
||||
|
||||
/**
|
||||
* Converts ElementAttributes[][] to PptxSlideModel[]
|
||||
* Each inner array represents elements on a slide
|
||||
*/
|
||||
export function convertElementAttributesToPptxSlides(
|
||||
slidesAttributes: ElementAttributes[][],
|
||||
backgroundColors?: (string | undefined)[]
|
||||
): PptxSlideModel[] {
|
||||
return slidesAttributes.map((slideElements, index) => {
|
||||
const shapes = slideElements.map(element => {
|
||||
return convertElementToPptxShape(element);
|
||||
}).filter(Boolean); // Remove any null/undefined shapes
|
||||
|
||||
const slide: PptxSlideModel = {
|
||||
shapes: shapes as (PptxTextBoxModel | PptxAutoShapeBoxModel | PptxConnectorModel | PptxPictureBoxModel)[]
|
||||
};
|
||||
|
||||
// Add background color if available
|
||||
if (backgroundColors && backgroundColors[index]) {
|
||||
slide.background = {
|
||||
color: backgroundColors[index]
|
||||
};
|
||||
}
|
||||
|
||||
return slide;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a single ElementAttributes to the appropriate PPTX shape model
|
||||
*/
|
||||
function convertElementToPptxShape(
|
||||
element: ElementAttributes
|
||||
): PptxTextBoxModel | PptxAutoShapeBoxModel | PptxConnectorModel | PptxPictureBoxModel | null {
|
||||
// Skip elements without position
|
||||
if (!element.position) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if it's an image element
|
||||
if (element.tagName === 'img' || element.className?.includes('image')) {
|
||||
return convertToPictureBox(element);
|
||||
}
|
||||
|
||||
// Check if it's a text element
|
||||
if (element.innerText && element.innerText.trim().length > 0) {
|
||||
return convertToTextBox(element);
|
||||
}
|
||||
|
||||
// Check if it's a connector/line element
|
||||
if (element.tagName === 'hr' || element.className?.includes('connector') || element.className?.includes('line')) {
|
||||
return convertToConnector(element);
|
||||
}
|
||||
|
||||
// Default to auto shape box for other elements
|
||||
return convertToAutoShapeBox(element);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts element to PptxTextBoxModel
|
||||
*/
|
||||
function convertToTextBox(element: ElementAttributes): PptxTextBoxModel {
|
||||
const position: PptxPositionModel = {
|
||||
left: element.position?.left,
|
||||
top: element.position?.top,
|
||||
width: element.position?.width,
|
||||
height: element.position?.height
|
||||
};
|
||||
|
||||
const margin: PptxSpacingModel | undefined = element.margin ? {
|
||||
top: element.margin.top,
|
||||
bottom: element.margin.bottom,
|
||||
left: element.margin.left,
|
||||
right: element.margin.right
|
||||
} : undefined;
|
||||
|
||||
const fill: PptxFillModel | undefined = element.background?.color ? {
|
||||
color: element.background.color
|
||||
} : undefined;
|
||||
|
||||
const font: PptxFontModel | undefined = element.font ? {
|
||||
name: element.font.name,
|
||||
size: element.font.size,
|
||||
bold: element.font.weight ? element.font.weight >= 600 : undefined,
|
||||
italic: element.font.italic,
|
||||
color: element.font.color
|
||||
} : undefined;
|
||||
|
||||
const paragraph: PptxParagraphModel = {
|
||||
spacing: undefined,
|
||||
alignment: element.textAlign,
|
||||
font,
|
||||
text: element.innerText
|
||||
};
|
||||
|
||||
return {
|
||||
margin,
|
||||
fill,
|
||||
position,
|
||||
text_wrap: element.textWrap ?? true,
|
||||
paragraphs: [paragraph]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts element to PptxAutoShapeBoxModel
|
||||
*/
|
||||
function convertToAutoShapeBox(element: ElementAttributes): PptxAutoShapeBoxModel {
|
||||
const position: PptxPositionModel = {
|
||||
left: element.position?.left,
|
||||
top: element.position?.top,
|
||||
width: element.position?.width,
|
||||
height: element.position?.height
|
||||
};
|
||||
|
||||
const margin: PptxSpacingModel | undefined = element.margin ? {
|
||||
top: element.margin.top,
|
||||
bottom: element.margin.bottom,
|
||||
left: element.margin.left,
|
||||
right: element.margin.right
|
||||
} : undefined;
|
||||
|
||||
const fill: PptxFillModel | undefined = element.background?.color ? {
|
||||
color: element.background.color
|
||||
} : undefined;
|
||||
|
||||
const stroke: PptxStrokeModel | undefined = element.border?.color ? {
|
||||
color: element.border.color,
|
||||
thickness: element.border.width || 1
|
||||
} : undefined;
|
||||
|
||||
const shadow: PptxShadowModel | undefined = element.shadow?.color ? {
|
||||
radius: element.shadow.radius ?? 4,
|
||||
offset: element.shadow.offset ? Math.sqrt(element.shadow.offset[0] ** 2 + element.shadow.offset[1] ** 2) : undefined,
|
||||
color: element.shadow.color,
|
||||
opacity: element.shadow.opacity,
|
||||
angle: element.shadow.angle
|
||||
} : undefined;
|
||||
|
||||
// Check if element has text content
|
||||
const paragraphs: PptxParagraphModel[] | undefined = element.innerText ? [{
|
||||
spacing: undefined,
|
||||
alignment: element.textAlign,
|
||||
font: element.font ? {
|
||||
name: element.font.name,
|
||||
size: element.font.size,
|
||||
bold: element.font.weight ? element.font.weight >= 600 : undefined,
|
||||
italic: element.font.italic,
|
||||
color: element.font.color
|
||||
} : undefined,
|
||||
text: element.innerText
|
||||
}] : undefined;
|
||||
|
||||
return {
|
||||
margin,
|
||||
fill,
|
||||
stroke,
|
||||
shadow,
|
||||
position,
|
||||
text_wrap: element.textWrap ?? true,
|
||||
border_radius: element.borderRadius ? (Array.isArray(element.borderRadius) ? element.borderRadius[0] : element.borderRadius) : 0,
|
||||
paragraphs
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts element to PptxPictureBoxModel
|
||||
*/
|
||||
function convertToPictureBox(element: ElementAttributes): PptxPictureBoxModel {
|
||||
const position: PptxPositionModel = {
|
||||
left: element.position?.left,
|
||||
top: element.position?.top,
|
||||
width: element.position?.width,
|
||||
height: element.position?.height
|
||||
};
|
||||
|
||||
const margin: PptxSpacingModel | undefined = element.margin ? {
|
||||
top: element.margin.top,
|
||||
bottom: element.margin.bottom,
|
||||
left: element.margin.left,
|
||||
right: element.margin.right
|
||||
} : undefined;
|
||||
|
||||
const objectFit: PptxObjectFitModel = {
|
||||
fit: element.objectFit ? (element.objectFit as PptxObjectFitEnum) : PptxObjectFitEnum.CONTAIN
|
||||
};
|
||||
|
||||
// Extract image path from element attributes
|
||||
const picture: PptxPictureModel = {
|
||||
is_network: element.imageSrc ? element.imageSrc.startsWith('http') : false,
|
||||
path: element.imageSrc || ''
|
||||
};
|
||||
|
||||
return {
|
||||
position,
|
||||
margin,
|
||||
clip: element.clip ?? false,
|
||||
overlay: element.overlay,
|
||||
border_radius: element.borderRadius ? (Array.isArray(element.borderRadius) ? element.borderRadius : [element.borderRadius]) : undefined,
|
||||
shape: element.shape ? (element.shape as PptxBoxShapeEnum) : PptxBoxShapeEnum.RECTANGLE,
|
||||
object_fit: objectFit,
|
||||
picture
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts element to PptxConnectorModel
|
||||
*/
|
||||
function convertToConnector(element: ElementAttributes): PptxConnectorModel {
|
||||
const position: PptxPositionModel = {
|
||||
left: element.position?.left,
|
||||
top: element.position?.top,
|
||||
width: element.position?.width,
|
||||
height: element.position?.height
|
||||
};
|
||||
|
||||
return {
|
||||
type: element.connectorType,
|
||||
position,
|
||||
thickness: element.border?.width || 1,
|
||||
color: element.border?.color || element.background?.color || '#000000'
|
||||
};
|
||||
}
|
||||
|
|
@ -3,29 +3,68 @@ import { store } from "@/store/store";
|
|||
|
||||
export const handleSaveLLMConfig = async (llmConfig: LLMConfig) => {
|
||||
if (!hasValidLLMConfig(llmConfig)) {
|
||||
throw new Error('Provided configuration is not valid');
|
||||
throw new Error("Provided configuration is not valid");
|
||||
}
|
||||
|
||||
await fetch('/api/user-config', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(llmConfig)
|
||||
console.log("StoreHelperLLMConfig: Saving LLM config", llmConfig);
|
||||
await fetch("/api/user-config", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(llmConfig),
|
||||
});
|
||||
|
||||
store.dispatch(setLLMConfig(llmConfig));
|
||||
}
|
||||
};
|
||||
|
||||
export const hasValidLLMConfig = (llmConfig: LLMConfig) => {
|
||||
if (!llmConfig.LLM) return false;
|
||||
if (!llmConfig.IMAGE_PROVIDER) return false;
|
||||
const OPENAI_API_KEY = llmConfig.OPENAI_API_KEY;
|
||||
const GOOGLE_API_KEY = llmConfig.GOOGLE_API_KEY;
|
||||
|
||||
const isOllamaConfigValid = llmConfig.OLLAMA_MODEL !== '' && llmConfig.OLLAMA_MODEL !== null && llmConfig.OLLAMA_MODEL !== undefined && llmConfig.OLLAMA_URL !== '' && llmConfig.OLLAMA_URL !== null && llmConfig.OLLAMA_URL !== undefined;
|
||||
const isCustomConfigValid = llmConfig.CUSTOM_LLM_URL !== '' && llmConfig.CUSTOM_LLM_URL !== null && llmConfig.CUSTOM_LLM_URL !== undefined && llmConfig.CUSTOM_MODEL !== '' && llmConfig.CUSTOM_MODEL !== null && llmConfig.CUSTOM_MODEL !== undefined;
|
||||
const isOllamaConfigValid =
|
||||
llmConfig.OLLAMA_MODEL !== "" &&
|
||||
llmConfig.OLLAMA_MODEL !== null &&
|
||||
llmConfig.OLLAMA_MODEL !== undefined &&
|
||||
llmConfig.OLLAMA_URL !== "" &&
|
||||
llmConfig.OLLAMA_URL !== null &&
|
||||
llmConfig.OLLAMA_URL !== undefined;
|
||||
|
||||
return llmConfig.LLM === 'openai' ?
|
||||
OPENAI_API_KEY !== '' && OPENAI_API_KEY !== null && OPENAI_API_KEY !== undefined :
|
||||
llmConfig.LLM === 'google' ?
|
||||
GOOGLE_API_KEY !== '' && GOOGLE_API_KEY !== null && GOOGLE_API_KEY !== undefined :
|
||||
llmConfig.LLM === 'ollama' ? isOllamaConfigValid :
|
||||
llmConfig.LLM === 'custom' ? isCustomConfigValid : false;
|
||||
}
|
||||
const isCustomConfigValid =
|
||||
llmConfig.CUSTOM_LLM_URL !== "" &&
|
||||
llmConfig.CUSTOM_LLM_URL !== null &&
|
||||
llmConfig.CUSTOM_LLM_URL !== undefined &&
|
||||
llmConfig.CUSTOM_MODEL !== "" &&
|
||||
llmConfig.CUSTOM_MODEL !== null &&
|
||||
llmConfig.CUSTOM_MODEL !== undefined;
|
||||
|
||||
const isImageConfigValid = () => {
|
||||
switch (llmConfig.IMAGE_PROVIDER) {
|
||||
case "pexels":
|
||||
return llmConfig.PEXELS_API_KEY && llmConfig.PEXELS_API_KEY !== "";
|
||||
case "pixabay":
|
||||
return llmConfig.PIXABAY_API_KEY && llmConfig.PIXABAY_API_KEY !== "";
|
||||
case "dall-e-3":
|
||||
return OPENAI_API_KEY && OPENAI_API_KEY !== "";
|
||||
case "gemini_flash":
|
||||
return GOOGLE_API_KEY && GOOGLE_API_KEY !== "";
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const isLLMConfigValid =
|
||||
llmConfig.LLM === "openai"
|
||||
? OPENAI_API_KEY !== "" &&
|
||||
OPENAI_API_KEY !== null &&
|
||||
OPENAI_API_KEY !== undefined
|
||||
: llmConfig.LLM === "google"
|
||||
? GOOGLE_API_KEY !== "" &&
|
||||
GOOGLE_API_KEY !== null &&
|
||||
GOOGLE_API_KEY !== undefined
|
||||
: llmConfig.LLM === "ollama"
|
||||
? isOllamaConfigValid
|
||||
: llmConfig.LLM === "custom"
|
||||
? isCustomConfigValid
|
||||
: false;
|
||||
|
||||
return isLLMConfigValid && isImageConfigValid();
|
||||
};
|
||||
|
|
|
|||
5
start.js
5
start.js
|
|
@ -1,3 +1,5 @@
|
|||
/* This script starts the FastAPI and Next.js servers, setting up user configuration if necessary. It reads environment variables to configure API keys and other settings, ensuring that the user configuration file is created if it doesn't exist. The script also handles the starting of both servers and keeps the Node.js process alive until one of the servers exits. */
|
||||
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
const fs = require('fs');
|
||||
|
|
@ -43,12 +45,13 @@ const setupUserConfigFromEnv = () => {
|
|||
CUSTOM_LLM_API_KEY: process.env.CUSTOM_LLM_API_KEY || existingConfig.CUSTOM_LLM_API_KEY,
|
||||
CUSTOM_MODEL: process.env.CUSTOM_MODEL || existingConfig.CUSTOM_MODEL,
|
||||
PEXELS_API_KEY: process.env.PEXELS_API_KEY || existingConfig.PEXELS_API_KEY,
|
||||
PIXABAY_API_KEY: process.env.PIXABAY_API_KEY || existingConfig.PIXABAY_API_KEY,
|
||||
IMAGE_PROVIDER: process.env.IMAGE_PROVIDER || existingConfig.IMAGE_PROVIDER,
|
||||
USE_CUSTOM_URL: process.env.USE_CUSTOM_URL || existingConfig.USE_CUSTOM_URL,
|
||||
};
|
||||
|
||||
fs.writeFileSync(userConfigPath, JSON.stringify(userConfig));
|
||||
}
|
||||
|
||||
const startServers = async () => {
|
||||
|
||||
const fastApiProcess = spawn(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue