feat: add support for optional embedded Ollama and enhance database migration handling
- Updated docker-compose.yml to allow disabling embedded Ollama via environment variable. - Refactored Dockerfile and Dockerfile.dev for improved dependency management and installation process. - Enhanced FastAPI migration scripts to handle orphaned Alembic revisions and added new database migration logic. - Improved error handling in background tasks and Codex authentication endpoints. - Added support for font file uploads with better validation and extraction of font names. - Introduced new image search functionality with support for Pexels and Pixabay APIs.
This commit is contained in:
parent
98cc548984
commit
c7860127f2
215 changed files with 20009 additions and 3494 deletions
6
.github/workflows/test-all.yml
vendored
6
.github/workflows/test-all.yml
vendored
|
|
@ -33,7 +33,8 @@ jobs:
|
|||
libreoffice \
|
||||
fontconfig \
|
||||
chromium \
|
||||
chromium-driver
|
||||
chromium-driver \
|
||||
imagemagick
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
|
|
@ -81,7 +82,8 @@ jobs:
|
|||
libreoffice \
|
||||
fontconfig \
|
||||
chromium \
|
||||
chromium-driver
|
||||
chromium-driver \
|
||||
imagemagick
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
|
|
|
|||
93
Dockerfile
93
Dockerfile
|
|
@ -1,62 +1,47 @@
|
|||
# syntax=docker/dockerfile:1.4
|
||||
FROM python:3.11-slim-bookworm
|
||||
|
||||
# Install Node.js and npm
|
||||
RUN apt-get update && apt-get install -y \
|
||||
nginx \
|
||||
curl \
|
||||
libreoffice \
|
||||
fontconfig \
|
||||
chromium \
|
||||
zstd
|
||||
|
||||
|
||||
# Install Node.js 20 using NodeSource repository
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
|
||||
apt-get install -y nodejs
|
||||
|
||||
|
||||
# Create a working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Set environment variables
|
||||
ENV APP_DATA_DIRECTORY=/app_data
|
||||
ENV TEMP_DIRECTORY=/tmp/presenton
|
||||
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||
|
||||
|
||||
# Install ollama
|
||||
RUN curl -fsSL https://ollama.com/install.sh | sh
|
||||
|
||||
# Install dependencies for FastAPI
|
||||
RUN pip install alembic aiohttp aiomysql aiosqlite asyncpg fastapi[standard] \
|
||||
pathvalidate pdfplumber chromadb sqlmodel \
|
||||
anthropic google-genai openai fastmcp dirtyjson
|
||||
RUN pip install docling --extra-index-url https://download.pytorch.org/whl/cpu
|
||||
|
||||
# Install dependencies for Next.js
|
||||
WORKDIR /app/servers/nextjs
|
||||
COPY servers/nextjs/package.json servers/nextjs/package-lock.json ./
|
||||
RUN npm install
|
||||
|
||||
|
||||
# Copy Next.js app
|
||||
COPY servers/nextjs/ /app/servers/nextjs/
|
||||
|
||||
# Build the Next.js app
|
||||
WORKDIR /app/servers/nextjs
|
||||
RUN npm run build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy FastAPI
|
||||
COPY servers/fastapi/ ./servers/fastapi/
|
||||
COPY start.js LICENSE NOTICE ./
|
||||
# Docling + CPU torch: declared in pyproject.toml; lockfile uses PyTorch CPU index.
|
||||
# UV_EXTRA_INDEX_URL mirrors the old `pip install docling --extra-index-url .../cpu`.
|
||||
ENV APP_DATA_DIRECTORY=/app_data \
|
||||
TEMP_DIRECTORY=/tmp/presenton \
|
||||
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium \
|
||||
UV_SYSTEM_PYTHON=1 \
|
||||
UV_COMPILE_BYTECODE=1 \
|
||||
UV_LINK_MODE=copy \
|
||||
UV_EXTRA_INDEX_URL=https://download.pytorch.org/whl/cpu \
|
||||
PATH="/root/.local/bin:${PATH}"
|
||||
|
||||
# Copy nginx configuration
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl \
|
||||
nginx libreoffice fontconfig chromium imagemagick zstd \
|
||||
&& curl -LsSf https://astral.sh/uv/install.sh | sh \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN curl -fsSL https://ollama.com/install.sh | sh
|
||||
|
||||
COPY servers/fastapi /app/servers/fastapi
|
||||
WORKDIR /app/servers/fastapi
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv export --frozen --no-dev --no-emit-project -o /tmp/requirements.txt \
|
||||
&& uv pip install --system -r /tmp/requirements.txt \
|
||||
&& uv pip install --system --no-deps .
|
||||
|
||||
WORKDIR /app/servers/nextjs
|
||||
COPY servers/nextjs/package.json servers/nextjs/package-lock.json ./
|
||||
RUN npm install
|
||||
COPY servers/nextjs/ /app/servers/nextjs/
|
||||
RUN npm run build
|
||||
|
||||
WORKDIR /app
|
||||
COPY start.js LICENSE NOTICE ./
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
# Expose the port
|
||||
EXPOSE 80
|
||||
|
||||
# Start the servers
|
||||
CMD ["node", "/app/start.js"]
|
||||
CMD ["node", "/app/start.js"]
|
||||
|
|
|
|||
|
|
@ -1,43 +1,41 @@
|
|||
# syntax=docker/dockerfile:1.4
|
||||
FROM python:3.11-slim-bookworm
|
||||
|
||||
# Install Node.js and npm
|
||||
RUN apt-get update && apt-get install -y \
|
||||
nginx \
|
||||
curl \
|
||||
libreoffice \
|
||||
fontconfig \
|
||||
chromium \
|
||||
zstd
|
||||
|
||||
# Install Node.js 20 using NodeSource repository
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
|
||||
apt-get install -y nodejs
|
||||
|
||||
|
||||
# Change working directory
|
||||
WORKDIR /app
|
||||
|
||||
RUN ls -a
|
||||
# Docling is in pyproject.toml; uv.lock pins torch to this index (same as former:
|
||||
# pip install docling --extra-index-url https://download.pytorch.org/whl/cpu
|
||||
# UV_EXTRA_INDEX_URL keeps CPU wheels available if the lock is refreshed in Docker.)
|
||||
ENV APP_DATA_DIRECTORY=/app_data \
|
||||
TEMP_DIRECTORY=/tmp/presenton \
|
||||
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium \
|
||||
UV_SYSTEM_PYTHON=1 \
|
||||
UV_COMPILE_BYTECODE=1 \
|
||||
UV_LINK_MODE=copy \
|
||||
UV_EXTRA_INDEX_URL=https://download.pytorch.org/whl/cpu \
|
||||
PATH="/root/.local/bin:${PATH}"
|
||||
|
||||
# Set environment variables
|
||||
ENV APP_DATA_DIRECTORY=/app_data
|
||||
ENV TEMP_DIRECTORY=/tmp/presenton
|
||||
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl \
|
||||
nginx libreoffice fontconfig chromium imagemagick zstd \
|
||||
&& curl -LsSf https://astral.sh/uv/install.sh | sh \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install ollama
|
||||
# RUN curl -fsSL http://ollama.com/install.sh | sh
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install dependencies for FastAPI
|
||||
RUN pip install alembic aiohttp aiomysql aiosqlite asyncpg fastapi[standard] \
|
||||
pathvalidate pdfplumber chromadb sqlmodel \
|
||||
anthropic google-genai openai fastmcp dirtyjson
|
||||
RUN pip install docling --extra-index-url https://download.pytorch.org/whl/cpu
|
||||
# Bind mount `.:/app` hides any .venv under servers/fastapi at runtime — install deps into
|
||||
# system site-packages (same interpreter `start.js` uses as `python`).
|
||||
COPY servers/fastapi /app/servers/fastapi
|
||||
WORKDIR /app/servers/fastapi
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv export --frozen --no-dev --no-emit-project -o /tmp/requirements.txt \
|
||||
&& uv pip install --system -r /tmp/requirements.txt \
|
||||
&& uv pip install --system --no-deps .
|
||||
|
||||
# Copy nginx configuration
|
||||
WORKDIR /app
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
# Expose the port
|
||||
EXPOSE 80
|
||||
|
||||
# Start the servers
|
||||
CMD ["node", "/app/start.js", "--dev"]
|
||||
|
|
|
|||
|
|
@ -94,6 +94,8 @@ services:
|
|||
- .:/app
|
||||
- ./app_data:/app_data
|
||||
environment:
|
||||
# Dockerfile.dev does not install ollama; use a host daemon via OLLAMA_URL or omit.
|
||||
- START_EMBEDDED_OLLAMA=false
|
||||
- MIGRATE_DATABASE_ON_STARTUP=true
|
||||
- CAN_CHANGE_KEYS=${CAN_CHANGE_KEYS}
|
||||
- LLM=${LLM}
|
||||
|
|
@ -138,6 +140,7 @@ services:
|
|||
- .:/app
|
||||
- ./app_data:/app_data
|
||||
environment:
|
||||
- START_EMBEDDED_OLLAMA=false
|
||||
- MIGRATE_DATABASE_ON_STARTUP=true
|
||||
- CAN_CHANGE_KEYS=${CAN_CHANGE_KEYS}
|
||||
- LLM=${LLM}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ from utils.get_env import get_migrate_database_on_startup_env
|
|||
|
||||
|
||||
LEGACY_BASELINE_REVISION = "00b3c27a13bc"
|
||||
# Revision before 95b5127e93cd (template_create_infos); used when DB has theme but not that table.
|
||||
REVISION_BEFORE_TEMPLATE_CREATE_INFO = "82abdbc476a7"
|
||||
|
||||
|
||||
async def migrate_database_on_startup() -> None:
|
||||
|
|
@ -49,6 +51,7 @@ def _run_migrations() -> None:
|
|||
database_url = _to_sync_database_url(database_url)
|
||||
|
||||
config.set_main_option("sqlalchemy.url", database_url)
|
||||
_repair_orphan_alembic_revision(config, database_url)
|
||||
_stamp_legacy_database_if_needed(config, database_url)
|
||||
|
||||
try:
|
||||
|
|
@ -62,6 +65,52 @@ def _run_migrations() -> None:
|
|||
raise
|
||||
|
||||
|
||||
def _repair_orphan_alembic_revision(config: Config, database_url: str) -> None:
|
||||
"""
|
||||
If alembic_version points at a revision id that no longer exists in alembic/versions
|
||||
(removed branch, old image, etc.), re-stamp from the live schema so upgrade can run.
|
||||
"""
|
||||
script = ScriptDirectory.from_config(config)
|
||||
known = {rev.revision for rev in script.walk_revisions()}
|
||||
heads = script.get_heads()
|
||||
if len(heads) != 1:
|
||||
return
|
||||
head = heads[0]
|
||||
|
||||
engine = create_engine(database_url)
|
||||
try:
|
||||
with engine.connect() as connection:
|
||||
inspector = inspect(connection)
|
||||
tables = set(inspector.get_table_names())
|
||||
if "alembic_version" not in tables:
|
||||
return
|
||||
version_num = connection.execute(
|
||||
text("SELECT version_num FROM alembic_version LIMIT 1")
|
||||
).scalar_one_or_none()
|
||||
if not version_num or version_num in known:
|
||||
return
|
||||
print(
|
||||
f"Alembic revision {version_num!r} is missing from the codebase; "
|
||||
"inferring applied migrations from schema and re-stamping.",
|
||||
flush=True,
|
||||
)
|
||||
target = _infer_revision_from_schema(inspector, tables, head)
|
||||
command.stamp(config, target)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _infer_revision_from_schema(inspector, tables: set[str], head_revision: str) -> str:
|
||||
"""Best-effort: map existing SQLite/Postgres schema to our linear migration chain."""
|
||||
if "template_create_infos" in tables:
|
||||
return head_revision
|
||||
if "presentations" in tables:
|
||||
cols = {c["name"] for c in inspector.get_columns("presentations")}
|
||||
if "theme" in cols:
|
||||
return REVISION_BEFORE_TEMPLATE_CREATE_INFO
|
||||
return LEGACY_BASELINE_REVISION
|
||||
|
||||
|
||||
def _stamp_legacy_database_if_needed(config: Config, database_url: str) -> None:
|
||||
"""
|
||||
If the DB has app tables but no migration reference in alembic_version,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ from models.sql.presentation_layout_code import ( # noqa: F401, E402
|
|||
)
|
||||
from models.sql.slide import SlideModel # noqa: F401, E402
|
||||
from models.sql.template import TemplateModel # noqa: F401, E402
|
||||
from models.sql.template_create_info import TemplateCreateInfoModel # noqa: F401, E402
|
||||
from models.sql.webhook_subscription import WebhookSubscription # noqa: F401, E402
|
||||
|
||||
alembic_config = context.config
|
||||
|
|
@ -33,6 +34,20 @@ if alembic_config.config_file_name is not None:
|
|||
|
||||
target_metadata = SQLModel.metadata
|
||||
|
||||
# alembic.ini sets this so Config validates; treat it as "unset" for URL resolution.
|
||||
_CLI_PLACEHOLDER_DB_URL = "sqlite:///placeholder"
|
||||
|
||||
|
||||
def _to_sync_database_url(database_url: str) -> str:
|
||||
# Preserve slash counts for sqlite URLs so Windows paths stay valid.
|
||||
if database_url.startswith("sqlite+aiosqlite:///"):
|
||||
return "sqlite:///" + database_url[len("sqlite+aiosqlite:///") :]
|
||||
if database_url.startswith("postgresql+asyncpg://"):
|
||||
return "postgresql://" + database_url[len("postgresql+asyncpg://") :]
|
||||
if database_url.startswith("mysql+aiomysql://"):
|
||||
return "mysql://" + database_url[len("mysql+aiomysql://") :]
|
||||
return database_url
|
||||
|
||||
|
||||
def _get_url() -> str:
|
||||
"""
|
||||
|
|
@ -40,18 +55,13 @@ def _get_url() -> str:
|
|||
falling back to the DATABASE_URL environment variable or a local SQLite DB.
|
||||
"""
|
||||
configured = alembic_config.get_main_option("sqlalchemy.url")
|
||||
if configured:
|
||||
if configured and configured != _CLI_PLACEHOLDER_DB_URL:
|
||||
return configured
|
||||
|
||||
from utils.db_utils import get_database_url_and_connect_args
|
||||
|
||||
url, _ = get_database_url_and_connect_args()
|
||||
return (
|
||||
url
|
||||
.replace("sqlite+aiosqlite://", "sqlite:///")
|
||||
.replace("postgresql+asyncpg://", "postgresql://")
|
||||
.replace("mysql+aiomysql://", "mysql://")
|
||||
)
|
||||
return _to_sync_database_url(url)
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
"""template create info
|
||||
|
||||
Revision ID: 95b5127e93cd
|
||||
Revises: 82abdbc476a7
|
||||
Create Date: 2026-04-08 13:44:21.132802
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '95b5127e93cd'
|
||||
down_revision: Union[str, None] = '82abdbc476a7'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('template_create_infos',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('fonts', sa.JSON(), nullable=True),
|
||||
sa.Column('pptx_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('slide_htmls', sa.JSON(), nullable=False),
|
||||
sa.Column('slide_image_urls', sa.JSON(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('template_create_infos')
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -1,20 +1,34 @@
|
|||
import os
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from api.lifespan import app_lifespan
|
||||
from api.middlewares import UserConfigEnvUpdateMiddleware
|
||||
from api.v1.mock.router import API_V1_MOCK_ROUTER
|
||||
from api.v1.ppt.router import API_V1_PPT_ROUTER
|
||||
from api.v1.webhook.router import API_V1_WEBHOOK_ROUTER
|
||||
from api.v1.mock.router import API_V1_MOCK_ROUTER
|
||||
|
||||
from utils.get_env import get_app_data_directory_env
|
||||
from utils.path_helpers import get_resource_path
|
||||
|
||||
app = FastAPI(lifespan=app_lifespan)
|
||||
|
||||
|
||||
# Routers
|
||||
app.include_router(API_V1_PPT_ROUTER)
|
||||
app.include_router(API_V1_WEBHOOK_ROUTER)
|
||||
app.include_router(API_V1_MOCK_ROUTER)
|
||||
|
||||
# Mount app_data and static assets (direct FastAPI access; nginx also serves /static in Docker).
|
||||
app_data_dir = get_app_data_directory_env()
|
||||
if app_data_dir:
|
||||
os.makedirs(app_data_dir, exist_ok=True)
|
||||
app.mount("/app_data", StaticFiles(directory=app_data_dir), name="app_data")
|
||||
|
||||
static_dir = get_resource_path("static")
|
||||
if os.path.isdir(static_dir):
|
||||
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||
|
||||
# Middlewares
|
||||
origins = ["*"]
|
||||
app.add_middleware(
|
||||
|
|
|
|||
|
|
@ -21,6 +21,14 @@ async def pull_ollama_model_background_task(model: str):
|
|||
|
||||
try:
|
||||
async for event in pull_ollama_model(model):
|
||||
if "error" in event:
|
||||
saved_model_status.status = "error"
|
||||
saved_model_status.done = True
|
||||
saved_model_status.error = event["error"]
|
||||
await upsert_ollama_pull_status(session, model, saved_model_status)
|
||||
await session.close()
|
||||
return
|
||||
|
||||
log_event_count += 1
|
||||
if log_event_count != 1 and log_event_count % 20 != 0:
|
||||
continue
|
||||
|
|
@ -39,6 +47,7 @@ async def pull_ollama_model_background_task(model: str):
|
|||
except Exception as e:
|
||||
saved_model_status.status = "error"
|
||||
saved_model_status.done = True
|
||||
saved_model_status.error = str(e)
|
||||
await upsert_ollama_pull_status(session, model, saved_model_status)
|
||||
await session.close()
|
||||
raise HTTPException(
|
||||
|
|
@ -49,6 +58,7 @@ async def pull_ollama_model_background_task(model: str):
|
|||
saved_model_status.done = True
|
||||
saved_model_status.status = "pulled"
|
||||
saved_model_status.downloaded = saved_model_status.size
|
||||
saved_model_status.error = None
|
||||
|
||||
await upsert_ollama_pull_status(session, model, saved_model_status)
|
||||
await session.close()
|
||||
|
|
|
|||
|
|
@ -16,25 +16,32 @@ from fastapi import APIRouter, HTTPException
|
|||
from pydantic import BaseModel
|
||||
|
||||
from utils.oauth.openai_codex import (
|
||||
CodexAccountProfile,
|
||||
OAuthCallbackServer,
|
||||
TokenSuccess,
|
||||
create_authorization_flow,
|
||||
exchange_authorization_code,
|
||||
get_account_id,
|
||||
get_account_profile,
|
||||
parse_authorization_input,
|
||||
refresh_access_token,
|
||||
)
|
||||
from utils.get_env import (
|
||||
get_codex_access_token_env,
|
||||
get_codex_email_env,
|
||||
get_codex_is_pro_env,
|
||||
get_codex_refresh_token_env,
|
||||
get_codex_token_expires_env,
|
||||
get_codex_username_env,
|
||||
)
|
||||
from utils.set_env import (
|
||||
set_codex_access_token_env,
|
||||
set_codex_account_id_env,
|
||||
set_codex_email_env,
|
||||
set_codex_is_pro_env,
|
||||
set_codex_refresh_token_env,
|
||||
set_codex_token_expires_env,
|
||||
set_codex_model_env,
|
||||
set_codex_username_env,
|
||||
)
|
||||
from utils.user_config import save_codex_tokens_to_user_config
|
||||
|
||||
|
|
@ -60,6 +67,9 @@ class InitiateResponse(BaseModel):
|
|||
class StatusResponse(BaseModel):
|
||||
status: str # "pending" | "success" | "failed"
|
||||
account_id: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
is_pro: Optional[bool] = None
|
||||
detail: Optional[str] = None
|
||||
|
||||
|
||||
|
|
@ -69,11 +79,17 @@ class ExchangeRequest(BaseModel):
|
|||
|
||||
|
||||
class ExchangeResponse(BaseModel):
|
||||
account_id: str
|
||||
account_id: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
is_pro: Optional[bool] = None
|
||||
|
||||
|
||||
class RefreshResponse(BaseModel):
|
||||
account_id: Optional[str]
|
||||
username: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
is_pro: Optional[bool] = None
|
||||
detail: str
|
||||
|
||||
|
||||
|
|
@ -81,16 +97,31 @@ class RefreshResponse(BaseModel):
|
|||
# Helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _store_token(result: TokenSuccess) -> Optional[str]:
|
||||
"""Persist token fields in env vars and userConfig.json. Returns account_id or None."""
|
||||
def _parse_optional_bool(value: Optional[str]) -> Optional[bool]:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {"true", "1", "yes", "y"}:
|
||||
return True
|
||||
if normalized in {"false", "0", "no", "n"}:
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def _store_token(result: TokenSuccess) -> CodexAccountProfile:
|
||||
"""Persist token fields in env vars and userConfig.json. Returns parsed profile."""
|
||||
set_codex_access_token_env(result.access)
|
||||
set_codex_refresh_token_env(result.refresh)
|
||||
set_codex_token_expires_env(str(result.expires))
|
||||
account_id = get_account_id(result.access)
|
||||
if account_id:
|
||||
set_codex_account_id_env(account_id)
|
||||
|
||||
profile = get_account_profile(result.access, result.id_token)
|
||||
set_codex_account_id_env(profile.account_id or "")
|
||||
set_codex_username_env(profile.username or "")
|
||||
set_codex_email_env(profile.email or "")
|
||||
set_codex_is_pro_env("" if profile.is_pro is None else str(profile.is_pro))
|
||||
|
||||
save_codex_tokens_to_user_config()
|
||||
return account_id
|
||||
return profile
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -166,8 +197,14 @@ async def poll_codex_auth_status(session_id: str):
|
|||
if not isinstance(result, TokenSuccess):
|
||||
return StatusResponse(status="failed", detail=result.reason)
|
||||
|
||||
account_id = _store_token(result)
|
||||
return StatusResponse(status="success", account_id=account_id)
|
||||
profile = _store_token(result)
|
||||
return StatusResponse(
|
||||
status="success",
|
||||
account_id=profile.account_id,
|
||||
username=profile.username,
|
||||
email=profile.email,
|
||||
is_pro=profile.is_pro,
|
||||
)
|
||||
|
||||
|
||||
@CODEX_AUTH_ROUTER.post("/exchange", response_model=ExchangeResponse)
|
||||
|
|
@ -207,11 +244,16 @@ async def exchange_codex_code(body: ExchangeRequest):
|
|||
if not isinstance(result, TokenSuccess):
|
||||
raise HTTPException(status_code=502, detail=f"Token exchange failed: {result.reason}")
|
||||
|
||||
account_id = _store_token(result)
|
||||
if not account_id:
|
||||
profile = _store_token(result)
|
||||
if not profile.account_id:
|
||||
raise HTTPException(status_code=502, detail="Token exchanged but could not extract account ID")
|
||||
|
||||
return ExchangeResponse(account_id=account_id)
|
||||
return ExchangeResponse(
|
||||
account_id=profile.account_id,
|
||||
username=profile.username,
|
||||
email=profile.email,
|
||||
is_pro=profile.is_pro,
|
||||
)
|
||||
|
||||
|
||||
@CODEX_AUTH_ROUTER.post("/refresh", response_model=RefreshResponse)
|
||||
|
|
@ -232,9 +274,12 @@ async def refresh_codex_token():
|
|||
if not isinstance(result, TokenSuccess):
|
||||
raise HTTPException(status_code=502, detail=f"Token refresh failed: {result.reason}")
|
||||
|
||||
account_id = _store_token(result)
|
||||
profile = _store_token(result)
|
||||
return RefreshResponse(
|
||||
account_id=account_id,
|
||||
account_id=profile.account_id,
|
||||
username=profile.username,
|
||||
email=profile.email,
|
||||
is_pro=profile.is_pro,
|
||||
detail="Token refreshed successfully",
|
||||
)
|
||||
|
||||
|
|
@ -260,8 +305,18 @@ async def get_codex_auth_status():
|
|||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
account_id = get_account_id(access_token)
|
||||
return StatusResponse(status="authenticated", account_id=account_id)
|
||||
profile = get_account_profile(access_token)
|
||||
return StatusResponse(
|
||||
status="authenticated",
|
||||
account_id=profile.account_id,
|
||||
username=profile.username or get_codex_username_env(),
|
||||
email=profile.email or get_codex_email_env(),
|
||||
is_pro=(
|
||||
profile.is_pro
|
||||
if profile.is_pro is not None
|
||||
else _parse_optional_bool(get_codex_is_pro_env())
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@CODEX_AUTH_ROUTER.post("/logout")
|
||||
|
|
@ -273,6 +328,9 @@ async def logout_codex():
|
|||
set_codex_refresh_token_env("")
|
||||
set_codex_token_expires_env("")
|
||||
set_codex_account_id_env("")
|
||||
set_codex_username_env("")
|
||||
set_codex_email_env("")
|
||||
set_codex_is_pro_env("")
|
||||
set_codex_model_env("")
|
||||
save_codex_tokens_to_user_config()
|
||||
return {"detail": "Logged out successfully"}
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ async def decompose_files(file_paths: Annotated[List[str], Body(embed=True)]):
|
|||
f"{uuid.uuid4()}.txt", temp_dir
|
||||
)
|
||||
parsed_doc = parsed_doc.replace("<br>", "\n")
|
||||
with open(file_path, "w") as text_file:
|
||||
with open(file_path, "w", encoding="utf-8") as text_file:
|
||||
text_file.write(parsed_doc)
|
||||
response.append(
|
||||
DecomposedFileInfo(
|
||||
|
|
|
|||
|
|
@ -1,251 +1,335 @@
|
|||
import os
|
||||
import uuid
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
import shutil
|
||||
from typing import List, Dict, Any, Optional
|
||||
from fastapi import APIRouter, HTTPException, File, UploadFile
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import select
|
||||
|
||||
from models.sql.key_value import KeyValueSqlModel
|
||||
from services.database import get_async_session
|
||||
from utils.get_env import get_app_data_directory_env
|
||||
from templates.preview import FontCheckResponse, check_fonts_in_pptx_handler
|
||||
from utils.asset_directory_utils import get_app_data_directory_env
|
||||
|
||||
try:
|
||||
from fontTools.ttLib import TTFont
|
||||
|
||||
from fontTools.ttLib.tables._n_a_m_e import table__n_a_m_e
|
||||
FONTTOOLS_AVAILABLE = True
|
||||
except ImportError:
|
||||
FONTTOOLS_AVAILABLE = False
|
||||
|
||||
FONTS_ROUTER = APIRouter(prefix="/fonts", tags=["fonts"])
|
||||
FONTS_STORAGE_KEY = "presentation_uploaded_fonts"
|
||||
|
||||
# Supported font file extensions
|
||||
SUPPORTED_FONT_EXTENSIONS = {
|
||||
".ttf": "font/ttf",
|
||||
".otf": "font/otf",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".eot": "application/vnd.ms-fontobject",
|
||||
'.ttf': 'font/ttf',
|
||||
'.otf': 'font/otf',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.eot': 'application/vnd.ms-fontobject'
|
||||
}
|
||||
|
||||
|
||||
class FontDetail(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
url: str
|
||||
|
||||
|
||||
class FontUploadResponse(FontDetail):
|
||||
success: bool = True
|
||||
class FontUploadResponse(BaseModel):
|
||||
success: bool
|
||||
font_name: str
|
||||
font_url: str
|
||||
font_path: str
|
||||
|
||||
message: Optional[str] = None
|
||||
|
||||
class FontListResponse(BaseModel):
|
||||
fonts: List[FontDetail]
|
||||
success: bool
|
||||
fonts: List[dict]
|
||||
message: Optional[str] = None
|
||||
|
||||
|
||||
def _get_fonts_directory() -> str:
|
||||
class UploadedFontsResponse(BaseModel):
|
||||
fonts: List[dict]
|
||||
|
||||
|
||||
def get_fonts_directory() -> str:
|
||||
"""Get the fonts directory path, create if it doesn't exist"""
|
||||
app_data_dir = get_app_data_directory_env() or "/tmp/presenton"
|
||||
fonts_dir = os.path.join(app_data_dir, "fonts")
|
||||
os.makedirs(fonts_dir, exist_ok=True)
|
||||
return fonts_dir
|
||||
|
||||
|
||||
def _extract_font_name_from_file(file_path: str, filename: str) -> str:
|
||||
fallback_name = os.path.splitext(filename)[0]
|
||||
|
||||
if not FONTTOOLS_AVAILABLE:
|
||||
return fallback_name
|
||||
|
||||
try:
|
||||
font = TTFont(file_path)
|
||||
if "name" not in font:
|
||||
font.close()
|
||||
return fallback_name
|
||||
|
||||
name_table = font["name"]
|
||||
for name_id in [1, 4, 6]:
|
||||
for record in name_table.names:
|
||||
if record.nameID == name_id:
|
||||
if record.langID in [0x409, 0]:
|
||||
font_name = record.toUnicode().strip()
|
||||
if font_name:
|
||||
font.close()
|
||||
return font_name
|
||||
|
||||
for record in name_table.names:
|
||||
if record.nameID == 1:
|
||||
font_name = record.toUnicode().strip()
|
||||
if font_name:
|
||||
font.close()
|
||||
return font_name
|
||||
|
||||
font.close()
|
||||
except Exception:
|
||||
return fallback_name
|
||||
|
||||
return fallback_name
|
||||
|
||||
|
||||
def _is_valid_font_file(file: UploadFile) -> bool:
|
||||
def is_valid_font_file(file: UploadFile) -> bool:
|
||||
"""Validate font file by extension and MIME type"""
|
||||
if not file.filename:
|
||||
return False
|
||||
|
||||
|
||||
file_ext = os.path.splitext(file.filename)[1].lower()
|
||||
if file_ext not in SUPPORTED_FONT_EXTENSIONS:
|
||||
return False
|
||||
|
||||
content_type = (file.content_type or "").lower()
|
||||
valid_mime_types = {
|
||||
"font/ttf",
|
||||
"font/otf",
|
||||
"font/woff",
|
||||
"font/woff2",
|
||||
"application/font-ttf",
|
||||
"application/font-otf",
|
||||
"application/font-woff",
|
||||
"application/font-woff2",
|
||||
"application/x-font-ttf",
|
||||
"application/x-font-otf",
|
||||
"font/truetype",
|
||||
"font/opentype",
|
||||
"application/octet-stream",
|
||||
"",
|
||||
}
|
||||
|
||||
|
||||
# Check MIME type
|
||||
content_type = file.content_type or ""
|
||||
valid_mime_types = [
|
||||
"font/ttf", "font/otf", "font/woff", "font/woff2",
|
||||
"application/font-ttf", "application/font-otf",
|
||||
"application/font-woff", "application/font-woff2",
|
||||
"application/x-font-ttf", "application/x-font-otf",
|
||||
"font/truetype", "font/opentype"
|
||||
]
|
||||
|
||||
return content_type in valid_mime_types
|
||||
|
||||
|
||||
async def _get_fonts_row(sql_session: AsyncSession) -> Optional[KeyValueSqlModel]:
|
||||
return await sql_session.scalar(
|
||||
select(KeyValueSqlModel).where(KeyValueSqlModel.key == FONTS_STORAGE_KEY)
|
||||
)
|
||||
|
||||
|
||||
def _read_fonts_from_row(row: Optional[KeyValueSqlModel]) -> list[dict[str, Any]]:
|
||||
if not row:
|
||||
return []
|
||||
value = row.value if isinstance(row.value, dict) else {}
|
||||
fonts = value.get("fonts", [])
|
||||
return fonts if isinstance(fonts, list) else []
|
||||
def extract_font_name_from_file(file_path: str) -> str:
|
||||
"""Extract the actual font family name from font file metadata"""
|
||||
if not FONTTOOLS_AVAILABLE:
|
||||
# Fallback to filename parsing if fonttools not available
|
||||
filename = os.path.basename(file_path)
|
||||
base_name = os.path.splitext(filename)[0]
|
||||
if '_' in filename and len(filename.split('_')[-1].split('.')[0]) == 8:
|
||||
# Remove UUID part
|
||||
parts = filename.split('_')
|
||||
if len(parts) > 1:
|
||||
return '_'.join(parts[:-1])
|
||||
return base_name
|
||||
|
||||
try:
|
||||
font = TTFont(file_path)
|
||||
|
||||
# Try to get font family name from name table
|
||||
if 'name' in font:
|
||||
name_table = font['name']
|
||||
|
||||
# Preferred order: Family name (ID 1), then Full name (ID 4), then PostScript name (ID 6)
|
||||
for name_id in [1, 4, 6]:
|
||||
for record in name_table.names:
|
||||
if record.nameID == name_id:
|
||||
# Prefer English names
|
||||
if record.langID == 0x409 or record.langID == 0: # English
|
||||
font_name = record.toUnicode().strip()
|
||||
if font_name:
|
||||
font.close()
|
||||
return font_name
|
||||
|
||||
# If no English name found, use any available family name
|
||||
for record in name_table.names:
|
||||
if record.nameID == 1: # Family name
|
||||
font_name = record.toUnicode().strip()
|
||||
if font_name:
|
||||
font.close()
|
||||
return font_name
|
||||
|
||||
font.close()
|
||||
except Exception as e:
|
||||
# If font parsing fails, fallback to filename
|
||||
print(f"Error reading font metadata from {file_path}: {e}")
|
||||
|
||||
# Fallback to filename parsing
|
||||
filename = os.path.basename(file_path)
|
||||
base_name = os.path.splitext(filename)[0]
|
||||
if '_' in filename and len(filename.split('_')[-1].split('.')[0]) == 8:
|
||||
# Remove UUID part
|
||||
parts = filename.split('_')
|
||||
if len(parts) > 1:
|
||||
return '_'.join(parts[:-1])
|
||||
return base_name
|
||||
|
||||
|
||||
@FONTS_ROUTER.post("/upload", response_model=FontUploadResponse)
|
||||
async def upload_font(
|
||||
file: Optional[UploadFile] = File(
|
||||
None, description="Font file to upload (.ttf, .otf, .woff, .woff2, .eot)"
|
||||
),
|
||||
font_file: Optional[UploadFile] = File(None),
|
||||
sql_session: AsyncSession = Depends(get_async_session),
|
||||
font_file: UploadFile = File(..., description="Font file to upload (.ttf, .otf, .woff, .woff2, .eot)")
|
||||
):
|
||||
upload_file = file or font_file
|
||||
if not upload_file:
|
||||
raise HTTPException(status_code=400, detail="No file provided")
|
||||
|
||||
if not upload_file.filename:
|
||||
raise HTTPException(status_code=400, detail="No file name provided")
|
||||
|
||||
if not _is_valid_font_file(upload_file):
|
||||
"""
|
||||
Upload a font file and save it to the fonts directory.
|
||||
|
||||
Args:
|
||||
font_file: Uploaded font file
|
||||
|
||||
Returns:
|
||||
FontUploadResponse with font details and accessible URL
|
||||
|
||||
Raises:
|
||||
HTTPException: If file validation fails or upload error occurs
|
||||
"""
|
||||
try:
|
||||
# Validate file
|
||||
if not font_file.filename:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No file name provided"
|
||||
)
|
||||
|
||||
if not is_valid_font_file(font_file):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid font file. Supported formats: {', '.join(SUPPORTED_FONT_EXTENSIONS.keys())}"
|
||||
)
|
||||
|
||||
# Generate unique filename to avoid conflicts
|
||||
file_ext = os.path.splitext(font_file.filename)[1].lower()
|
||||
base_name = os.path.splitext(font_file.filename)[0]
|
||||
unique_filename = f"{base_name}_{str(uuid.uuid4())[:8]}{file_ext}"
|
||||
|
||||
# Get fonts directory
|
||||
fonts_dir = get_fonts_directory()
|
||||
font_path = os.path.join(fonts_dir, unique_filename)
|
||||
|
||||
# Save the uploaded file
|
||||
with open(font_path, "wb") as buffer:
|
||||
shutil.copyfileobj(font_file.file, buffer)
|
||||
|
||||
# Generate accessible URL
|
||||
font_url = f"/app_data/fonts/{unique_filename}"
|
||||
|
||||
return FontUploadResponse(
|
||||
success=True,
|
||||
font_name=base_name,
|
||||
font_url=font_url,
|
||||
font_path=font_path,
|
||||
message=f"Font '{base_name}' uploaded successfully"
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
# Re-raise HTTP exceptions as-is
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"Error uploading font: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid font file. Supported formats: {', '.join(SUPPORTED_FONT_EXTENSIONS.keys())}",
|
||||
status_code=500,
|
||||
detail=f"Error uploading font: {str(e)}"
|
||||
)
|
||||
|
||||
file_ext = os.path.splitext(upload_file.filename)[1].lower()
|
||||
unique_filename = f"{uuid.uuid4().hex}{file_ext}"
|
||||
fonts_dir = _get_fonts_directory()
|
||||
font_path = os.path.join(fonts_dir, unique_filename)
|
||||
|
||||
@FONTS_ROUTER.get("/list", response_model=FontListResponse)
|
||||
async def list_fonts():
|
||||
"""
|
||||
List all uploaded fonts with their accessible URLs.
|
||||
|
||||
Returns:
|
||||
FontListResponse with list of available fonts
|
||||
"""
|
||||
try:
|
||||
contents = await upload_file.read()
|
||||
with open(font_path, "wb") as buffer:
|
||||
buffer.write(contents)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail="Error uploading font") from exc
|
||||
|
||||
font_name = _extract_font_name_from_file(font_path, upload_file.filename)
|
||||
font_url = f"/app_data/fonts/{unique_filename}"
|
||||
font_detail = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"name": font_name,
|
||||
"url": font_url,
|
||||
"path": font_path,
|
||||
}
|
||||
|
||||
row = await _get_fonts_row(sql_session)
|
||||
fonts = _read_fonts_from_row(row)
|
||||
fonts.append(font_detail)
|
||||
|
||||
if row:
|
||||
row.value = {"fonts": fonts}
|
||||
sql_session.add(row)
|
||||
else:
|
||||
sql_session.add(KeyValueSqlModel(key=FONTS_STORAGE_KEY, value={"fonts": fonts}))
|
||||
await sql_session.commit()
|
||||
|
||||
return FontUploadResponse(
|
||||
id=font_detail["id"],
|
||||
name=font_detail["name"],
|
||||
url=font_detail["url"],
|
||||
font_name=font_detail["name"],
|
||||
font_url=font_detail["url"],
|
||||
font_path=font_detail["path"],
|
||||
)
|
||||
fonts_dir = get_fonts_directory()
|
||||
fonts = []
|
||||
|
||||
# Get all font files in the directory
|
||||
if os.path.exists(fonts_dir):
|
||||
for filename in os.listdir(fonts_dir):
|
||||
file_path = os.path.join(fonts_dir, filename)
|
||||
|
||||
if os.path.isfile(file_path):
|
||||
file_ext = os.path.splitext(filename)[1].lower()
|
||||
|
||||
if file_ext in SUPPORTED_FONT_EXTENSIONS:
|
||||
# Get the real font name from file metadata
|
||||
font_name = extract_font_name_from_file(file_path)
|
||||
|
||||
# Extract original name (remove UUID suffix for display)
|
||||
base_name = filename
|
||||
if '_' in filename and len(filename.split('_')[-1].split('.')[0]) == 8:
|
||||
# Remove UUID part for original_name display
|
||||
parts = filename.split('_')
|
||||
if len(parts) > 1:
|
||||
base_name = '_'.join(parts[:-1]) + file_ext
|
||||
|
||||
fonts.append({
|
||||
"filename": filename,
|
||||
"font_name": font_name, # Real font family name from metadata
|
||||
"original_name": base_name,
|
||||
"font_url": f"/app_data/fonts/{filename}",
|
||||
"font_type": SUPPORTED_FONT_EXTENSIONS.get(file_ext, 'unknown'),
|
||||
"file_size": os.path.getsize(file_path)
|
||||
})
|
||||
|
||||
return FontListResponse(
|
||||
success=True,
|
||||
fonts=fonts,
|
||||
message=f"Found {len(fonts)} font files"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error listing fonts: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Error listing fonts: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@FONTS_ROUTER.get("/uploaded", response_model=FontListResponse)
|
||||
async def get_uploaded_fonts(sql_session: AsyncSession = Depends(get_async_session)):
|
||||
row = await _get_fonts_row(sql_session)
|
||||
fonts = _read_fonts_from_row(row)
|
||||
@FONTS_ROUTER.get("/uploaded", response_model=UploadedFontsResponse)
|
||||
async def get_uploaded_fonts():
|
||||
"""
|
||||
Compatibility endpoint used by frontend theme flow.
|
||||
Returns uploaded fonts as a compact list with id/name/url fields.
|
||||
"""
|
||||
try:
|
||||
fonts_dir = get_fonts_directory()
|
||||
fonts = []
|
||||
|
||||
valid_fonts = []
|
||||
for font in fonts:
|
||||
path = font.get("path")
|
||||
if isinstance(path, str) and os.path.exists(path):
|
||||
valid_fonts.append(font)
|
||||
if os.path.exists(fonts_dir):
|
||||
for filename in os.listdir(fonts_dir):
|
||||
file_path = os.path.join(fonts_dir, filename)
|
||||
if not os.path.isfile(file_path):
|
||||
continue
|
||||
|
||||
if row and len(valid_fonts) != len(fonts):
|
||||
row.value = {"fonts": valid_fonts}
|
||||
sql_session.add(row)
|
||||
await sql_session.commit()
|
||||
file_ext = os.path.splitext(filename)[1].lower()
|
||||
if file_ext not in SUPPORTED_FONT_EXTENSIONS:
|
||||
continue
|
||||
|
||||
return FontListResponse(
|
||||
fonts=[
|
||||
FontDetail(
|
||||
id=str(item.get("id", "")),
|
||||
name=str(item.get("name", "")),
|
||||
url=str(item.get("url", "")),
|
||||
font_name = extract_font_name_from_file(file_path)
|
||||
fonts.append(
|
||||
{
|
||||
"id": filename,
|
||||
"name": font_name,
|
||||
"url": f"/app_data/fonts/{filename}",
|
||||
}
|
||||
)
|
||||
|
||||
return UploadedFontsResponse(fonts=fonts)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting uploaded fonts: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Error getting uploaded fonts: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
FONTS_ROUTER.post("/check", response_model=FontCheckResponse)(check_fonts_in_pptx_handler)
|
||||
|
||||
|
||||
@FONTS_ROUTER.delete("/delete/{filename}")
|
||||
async def delete_font(filename: str):
|
||||
"""
|
||||
Delete a font file from the fonts directory.
|
||||
|
||||
Args:
|
||||
filename: Name of the font file to delete
|
||||
|
||||
Returns:
|
||||
Success message
|
||||
"""
|
||||
try:
|
||||
fonts_dir = get_fonts_directory()
|
||||
font_path = os.path.join(fonts_dir, filename)
|
||||
|
||||
if not os.path.exists(font_path):
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Font file '{filename}' not found"
|
||||
)
|
||||
for item in valid_fonts
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@FONTS_ROUTER.delete("/{font_id}", status_code=204)
|
||||
async def delete_uploaded_font(
|
||||
font_id: str, sql_session: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
row = await _get_fonts_row(sql_session)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Font not found")
|
||||
|
||||
fonts = _read_fonts_from_row(row)
|
||||
target_font = next((item for item in fonts if str(item.get("id")) == font_id), None)
|
||||
if not target_font:
|
||||
raise HTTPException(status_code=404, detail="Font not found")
|
||||
|
||||
path = target_font.get("path")
|
||||
if isinstance(path, str) and os.path.exists(path):
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
# Keep metadata cleanup resilient even if local file is already gone/locked.
|
||||
pass
|
||||
|
||||
updated_fonts = [item for item in fonts if str(item.get("id")) != font_id]
|
||||
row.value = {"fonts": updated_fonts}
|
||||
sql_session.add(row)
|
||||
await sql_session.commit()
|
||||
|
||||
# Validate it's actually a font file before deleting
|
||||
file_ext = os.path.splitext(filename.lower())[1]
|
||||
if file_ext not in SUPPORTED_FONT_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="File is not a recognized font format"
|
||||
)
|
||||
|
||||
os.remove(font_path)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Font '{filename}' deleted successfully"
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"Error deleting font: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Error deleting font: {str(e)}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from typing import List
|
||||
from fastapi import APIRouter, Depends, File, UploadFile, HTTPException
|
||||
from fastapi import APIRouter, Depends, File, UploadFile, HTTPException, Query, Header
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import select
|
||||
|
||||
|
|
@ -8,6 +8,9 @@ from models.sql.image_asset import ImageAsset
|
|||
from services.database import get_async_session
|
||||
from services.image_generation_service import ImageGenerationService
|
||||
from utils.asset_directory_utils import get_images_directory
|
||||
from utils.get_env import get_pexels_api_key_env, get_pixabay_api_key_env
|
||||
from utils.image_provider import get_selected_image_provider
|
||||
from enums.image_provider import ImageProvider
|
||||
import os
|
||||
import uuid
|
||||
from utils.file_utils import get_file_name_with_random_uuid
|
||||
|
|
@ -15,6 +18,75 @@ from utils.file_utils import get_file_name_with_random_uuid
|
|||
IMAGES_ROUTER = APIRouter(prefix="/images", tags=["Images"])
|
||||
|
||||
|
||||
def _normalize_stock_provider(provider: str | None) -> str:
|
||||
normalized_provider = (provider or "").strip().lower()
|
||||
if normalized_provider in {"pixels", "pixel", "pexel"}:
|
||||
normalized_provider = "pexels"
|
||||
|
||||
if normalized_provider:
|
||||
if normalized_provider in {"pexels", "pixabay"}:
|
||||
return normalized_provider
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="provider must be either 'pexels' or 'pixabay'",
|
||||
)
|
||||
|
||||
selected_provider = get_selected_image_provider()
|
||||
if selected_provider == ImageProvider.PIXABAY:
|
||||
return "pixabay"
|
||||
return "pexels"
|
||||
|
||||
|
||||
@IMAGES_ROUTER.get("/search", response_model=List[str])
|
||||
async def search_stock_images(
|
||||
query: str,
|
||||
limit: int = Query(default=12, ge=1, le=30),
|
||||
provider: str | None = Query(default=None),
|
||||
strict_api_key: bool = Query(default=False),
|
||||
x_provider_api_key: str | None = Header(default=None, alias="X-Provider-Api-Key"),
|
||||
):
|
||||
normalized_provider = _normalize_stock_provider(provider)
|
||||
|
||||
image_generation_service = ImageGenerationService(get_images_directory())
|
||||
|
||||
if normalized_provider == "pexels":
|
||||
api_key = (x_provider_api_key or get_pexels_api_key_env() or "").strip()
|
||||
if strict_api_key and not api_key:
|
||||
raise HTTPException(status_code=401, detail="Pexels API key is required")
|
||||
|
||||
# Pexels can return cached public responses for common queries.
|
||||
# Use a nonce query in strict mode to force a real auth check.
|
||||
if strict_api_key:
|
||||
validation_query = f"__presenton_auth_check_{uuid.uuid4().hex}"
|
||||
await image_generation_service.get_image_from_pexels(
|
||||
validation_query,
|
||||
api_key=api_key,
|
||||
limit=1,
|
||||
)
|
||||
|
||||
images = await image_generation_service.get_image_from_pexels(
|
||||
query,
|
||||
api_key=api_key,
|
||||
limit=limit,
|
||||
)
|
||||
if isinstance(images, str):
|
||||
return [images] if images else []
|
||||
return images
|
||||
|
||||
api_key = (x_provider_api_key or get_pixabay_api_key_env() or "").strip()
|
||||
if strict_api_key and not api_key:
|
||||
raise HTTPException(status_code=401, detail="Pixabay API key is required")
|
||||
|
||||
images = await image_generation_service.get_image_from_pixabay(
|
||||
query,
|
||||
api_key=api_key,
|
||||
limit=limit,
|
||||
)
|
||||
if isinstance(images, str):
|
||||
return [images] if images else []
|
||||
return images
|
||||
|
||||
|
||||
@IMAGES_ROUTER.get("/generate")
|
||||
async def generate_image(
|
||||
prompt: str, sql_session: AsyncSession = Depends(get_async_session)
|
||||
|
|
@ -30,17 +102,22 @@ async def generate_image(
|
|||
sql_session.add(image)
|
||||
await sql_session.commit()
|
||||
|
||||
return image.path
|
||||
return image.file_url
|
||||
|
||||
|
||||
@IMAGES_ROUTER.get("/generated", response_model=List[ImageAsset])
|
||||
async def get_generated_images(sql_session: AsyncSession = Depends(get_async_session)):
|
||||
try:
|
||||
images = await sql_session.scalars(
|
||||
images_result = await sql_session.scalars(
|
||||
select(ImageAsset)
|
||||
.where(ImageAsset.is_uploaded == False)
|
||||
.order_by(ImageAsset.created_at.desc())
|
||||
)
|
||||
images = list(images_result)
|
||||
for image in images:
|
||||
# Ensure path exposed to the frontend is a web-safe URL
|
||||
if hasattr(image, "file_url"):
|
||||
image.path = image.file_url # type: ignore[attr-defined]
|
||||
return images
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
|
|
@ -65,6 +142,12 @@ async def upload_image(
|
|||
|
||||
sql_session.add(image_asset)
|
||||
await sql_session.commit()
|
||||
# Refresh to ensure all defaults are loaded
|
||||
await sql_session.refresh(image_asset)
|
||||
|
||||
# Expose a web-safe URL in the path field for the frontend
|
||||
if hasattr(image_asset, "file_url"):
|
||||
image_asset.path = image_asset.file_url # type: ignore[attr-defined]
|
||||
|
||||
return image_asset
|
||||
except Exception as e:
|
||||
|
|
@ -74,11 +157,16 @@ async def upload_image(
|
|||
@IMAGES_ROUTER.get("/uploaded", response_model=List[ImageAsset])
|
||||
async def get_uploaded_images(sql_session: AsyncSession = Depends(get_async_session)):
|
||||
try:
|
||||
images = await sql_session.scalars(
|
||||
images_result = await sql_session.scalars(
|
||||
select(ImageAsset)
|
||||
.where(ImageAsset.is_uploaded == True)
|
||||
.order_by(ImageAsset.created_at.desc())
|
||||
)
|
||||
images = list(images_result)
|
||||
for image in images:
|
||||
# Ensure path exposed to the frontend is a web-safe URL
|
||||
if hasattr(image, "file_url"):
|
||||
image.path = image.file_url # type: ignore[attr-defined]
|
||||
return images
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
from fastapi import APIRouter, HTTPException
|
||||
import aiohttp
|
||||
from typing import List, Any
|
||||
from utils.get_layout_by_name import get_layout_by_name
|
||||
from models.presentation_layout import PresentationLayoutModel
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import asyncio
|
||||
import json
|
||||
import math
|
||||
import traceback
|
||||
import uuid
|
||||
import dirtyjson
|
||||
|
|
@ -19,8 +18,11 @@ from models.sse_response import (
|
|||
from services.temp_file_service import TEMP_FILE_SERVICE
|
||||
from services.database import get_async_session
|
||||
from services.documents_loader import DocumentsLoader
|
||||
from utils.outline_utils import (
|
||||
get_no_of_outlines_to_generate_for_n_slides,
|
||||
get_presentation_title_from_presentation_outline,
|
||||
)
|
||||
from utils.llm_calls.generate_presentation_outlines import generate_ppt_outline
|
||||
from utils.ppt_utils import get_presentation_title_from_outlines
|
||||
|
||||
OUTLINES_ROUTER = APIRouter(prefix="/outlines", tags=["Outlines"])
|
||||
|
||||
|
|
@ -43,7 +45,10 @@ async def stream_outlines(
|
|||
|
||||
additional_context = ""
|
||||
if presentation.file_paths:
|
||||
documents_loader = DocumentsLoader(file_paths=presentation.file_paths)
|
||||
documents_loader = DocumentsLoader(
|
||||
file_paths=presentation.file_paths,
|
||||
presentation_language=presentation.language,
|
||||
)
|
||||
await documents_loader.load_documents(temp_dir)
|
||||
documents = documents_loader.documents
|
||||
if documents:
|
||||
|
|
@ -51,12 +56,14 @@ async def stream_outlines(
|
|||
|
||||
presentation_outlines_text = ""
|
||||
|
||||
n_slides_to_generate = presentation.n_slides
|
||||
if presentation.include_table_of_contents:
|
||||
needed_toc_count = math.ceil((presentation.n_slides - 1) / 10)
|
||||
n_slides_to_generate -= math.ceil(
|
||||
(presentation.n_slides - needed_toc_count) / 10
|
||||
if presentation.n_slides > 0:
|
||||
n_slides_to_generate = get_no_of_outlines_to_generate_for_n_slides(
|
||||
n_slides=presentation.n_slides,
|
||||
toc=presentation.include_table_of_contents,
|
||||
title_slide=presentation.include_title_slide,
|
||||
)
|
||||
else:
|
||||
n_slides_to_generate = None
|
||||
|
||||
async for chunk in generate_ppt_outline(
|
||||
presentation.content,
|
||||
|
|
@ -68,6 +75,7 @@ async def stream_outlines(
|
|||
presentation.instructions,
|
||||
presentation.include_title_slide,
|
||||
presentation.web_search,
|
||||
presentation.include_table_of_contents,
|
||||
):
|
||||
# Give control to the event loop
|
||||
await asyncio.sleep(0)
|
||||
|
|
@ -96,12 +104,30 @@ async def stream_outlines(
|
|||
|
||||
presentation_outlines = PresentationOutlineModel(**presentation_outlines_json)
|
||||
|
||||
presentation_outlines.slides = presentation_outlines.slides[
|
||||
:n_slides_to_generate
|
||||
]
|
||||
if (
|
||||
n_slides_to_generate is not None
|
||||
and len(presentation_outlines.slides) != n_slides_to_generate
|
||||
):
|
||||
yield SSEErrorResponse(
|
||||
detail=(
|
||||
"Failed to generate presentation outlines with requested "
|
||||
"number of slides. Please try again."
|
||||
)
|
||||
).to_string()
|
||||
return
|
||||
|
||||
if n_slides_to_generate is not None:
|
||||
presentation_outlines.slides = presentation_outlines.slides[
|
||||
:n_slides_to_generate
|
||||
]
|
||||
|
||||
if presentation.n_slides <= 0:
|
||||
presentation.n_slides = len(presentation_outlines.slides)
|
||||
|
||||
presentation.outlines = presentation_outlines.model_dump()
|
||||
presentation.title = get_presentation_title_from_outlines(presentation_outlines)
|
||||
presentation.title = get_presentation_title_from_presentation_outline(
|
||||
presentation_outlines
|
||||
)
|
||||
|
||||
sql_session.add(presentation)
|
||||
await sql_session.commit()
|
||||
|
|
|
|||
|
|
@ -1,18 +1,17 @@
|
|||
import asyncio
|
||||
from datetime import datetime
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import traceback
|
||||
from typing import Annotated, List, Literal, Optional, Tuple
|
||||
import dirtyjson
|
||||
from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, Path, Request
|
||||
from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, Path
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import select
|
||||
from constants.presentation import DEFAULT_TEMPLATES
|
||||
from constants.presentation import DEFAULT_TEMPLATES, MAX_NUMBER_OF_SLIDES
|
||||
from enums.webhook_event import WebhookEvent
|
||||
from models.api_error_model import APIErrorModel
|
||||
from models.generate_presentation_request import GeneratePresentationRequest
|
||||
|
|
@ -25,21 +24,19 @@ from models.presentation_outline_model import (
|
|||
from enums.tone import Tone
|
||||
from enums.verbosity import Verbosity
|
||||
from models.pptx_models import PptxPresentationModel
|
||||
from models.presentation_layout import PresentationLayoutModel
|
||||
from models.presentation_structure_model import PresentationStructureModel
|
||||
from models.presentation_with_slides import (
|
||||
PresentationWithSlides,
|
||||
)
|
||||
from models.sql.template import TemplateModel
|
||||
|
||||
from services.documents_loader import DocumentsLoader
|
||||
from services.webhook_service import WebhookService
|
||||
from utils.get_layout_by_name import get_layout_by_name
|
||||
from services.image_generation_service import ImageGenerationService
|
||||
from utils.dict_utils import deep_update
|
||||
from utils.export_utils import export_presentation
|
||||
from utils.llm_calls.generate_presentation_outlines import generate_ppt_outline
|
||||
from models.sql.slide import SlideModel
|
||||
from models.sql.presentation_layout_code import PresentationLayoutCodeModel
|
||||
from models.sse_response import SSECompleteResponse, SSEErrorResponse, SSEResponse
|
||||
|
||||
from services.database import get_async_session
|
||||
|
|
@ -58,23 +55,88 @@ from utils.llm_calls.generate_slide_content import (
|
|||
get_slide_content_from_type_and_outline,
|
||||
)
|
||||
from utils.ppt_utils import (
|
||||
get_presentation_title_from_outlines,
|
||||
select_toc_or_list_slide_layout_index,
|
||||
)
|
||||
from utils.outline_utils import (
|
||||
get_images_for_slides_from_outline,
|
||||
get_no_of_outlines_to_generate_for_n_slides,
|
||||
get_no_of_toc_required_for_n_outlines,
|
||||
get_presentation_outline_model_with_toc,
|
||||
get_presentation_title_from_presentation_outline,
|
||||
)
|
||||
from utils.process_slides import (
|
||||
process_slide_add_placeholder_assets,
|
||||
process_slide_and_fetch_assets,
|
||||
)
|
||||
from utils.get_layout_by_name import get_layout_by_name
|
||||
from models.presentation_layout import PresentationLayoutModel
|
||||
import uuid
|
||||
|
||||
|
||||
PRESENTATION_ROUTER = APIRouter(prefix="/presentation", tags=["Presentation"])
|
||||
|
||||
|
||||
def _extract_custom_template_id(layout_name: Optional[str]) -> Optional[uuid.UUID]:
|
||||
if not layout_name or not layout_name.startswith("custom-"):
|
||||
return None
|
||||
try:
|
||||
return uuid.UUID(layout_name.replace("custom-", ""))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def _resolve_presentation_fonts(
|
||||
presentation: PresentationModel,
|
||||
slides: List[SlideModel],
|
||||
sql_session: AsyncSession,
|
||||
):
|
||||
candidate_template_ids: List[uuid.UUID] = []
|
||||
seen = set()
|
||||
|
||||
layout_name = None
|
||||
if isinstance(presentation.layout, dict):
|
||||
layout_name = presentation.layout.get("name")
|
||||
layout_template_id = _extract_custom_template_id(layout_name)
|
||||
if layout_template_id and layout_template_id not in seen:
|
||||
candidate_template_ids.append(layout_template_id)
|
||||
seen.add(layout_template_id)
|
||||
|
||||
for slide in slides:
|
||||
template_id = _extract_custom_template_id(slide.layout_group)
|
||||
if template_id and template_id not in seen:
|
||||
candidate_template_ids.append(template_id)
|
||||
seen.add(template_id)
|
||||
|
||||
for template_id in candidate_template_ids:
|
||||
result = await sql_session.execute(
|
||||
select(PresentationLayoutCodeModel.fonts).where(
|
||||
PresentationLayoutCodeModel.presentation == template_id
|
||||
)
|
||||
)
|
||||
fonts_list = result.scalars().all()
|
||||
for fonts in fonts_list:
|
||||
if fonts is not None:
|
||||
return fonts
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _insert_toc_layouts(
|
||||
structure: PresentationStructureModel,
|
||||
n_toc_slides: int,
|
||||
include_title_slide: bool,
|
||||
toc_slide_layout_index: int,
|
||||
):
|
||||
if n_toc_slides <= 0 or toc_slide_layout_index == -1:
|
||||
return
|
||||
|
||||
insertion_index = 1 if include_title_slide else 0
|
||||
for i in range(n_toc_slides):
|
||||
structure.slides.insert(insertion_index + i, toc_slide_layout_index)
|
||||
|
||||
|
||||
@PRESENTATION_ROUTER.get("/all", response_model=List[PresentationWithSlides])
|
||||
async def get_all_presentations(sql_session: AsyncSession = Depends(get_async_session)):
|
||||
presentations_with_slides = []
|
||||
|
||||
query = (
|
||||
select(PresentationModel, SlideModel)
|
||||
.join(
|
||||
|
|
@ -86,13 +148,17 @@ async def get_all_presentations(sql_session: AsyncSession = Depends(get_async_se
|
|||
|
||||
results = await sql_session.execute(query)
|
||||
rows = results.all()
|
||||
presentations_with_slides = [
|
||||
PresentationWithSlides(
|
||||
**presentation.model_dump(),
|
||||
slides=[first_slide],
|
||||
presentations_with_slides = []
|
||||
for presentation, first_slide in rows:
|
||||
slides = [first_slide]
|
||||
fonts = await _resolve_presentation_fonts(presentation, slides, sql_session)
|
||||
presentations_with_slides.append(
|
||||
PresentationWithSlides(
|
||||
**presentation.model_dump(),
|
||||
slides=slides,
|
||||
fonts=fonts,
|
||||
)
|
||||
)
|
||||
for presentation, first_slide in rows
|
||||
]
|
||||
return presentations_with_slides
|
||||
|
||||
|
||||
|
|
@ -103,14 +169,17 @@ async def get_presentation(
|
|||
presentation = await sql_session.get(PresentationModel, id)
|
||||
if not presentation:
|
||||
raise HTTPException(404, "Presentation not found")
|
||||
slides = await sql_session.scalars(
|
||||
slides_result = await sql_session.scalars(
|
||||
select(SlideModel)
|
||||
.where(SlideModel.presentation == id)
|
||||
.order_by(SlideModel.index)
|
||||
)
|
||||
slides = list(slides_result)
|
||||
fonts = await _resolve_presentation_fonts(presentation, slides, sql_session)
|
||||
return PresentationWithSlides(
|
||||
**presentation.model_dump(),
|
||||
slides=slides,
|
||||
fonts=fonts,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -129,8 +198,8 @@ async def delete_presentation(
|
|||
@PRESENTATION_ROUTER.post("/create", response_model=PresentationModel)
|
||||
async def create_presentation(
|
||||
content: Annotated[str, Body()],
|
||||
n_slides: Annotated[int, Body()],
|
||||
language: Annotated[str, Body()],
|
||||
n_slides: Annotated[Optional[int], Body()] = None,
|
||||
language: Annotated[Optional[str], Body()] = None,
|
||||
file_paths: Annotated[Optional[List[str]], Body()] = None,
|
||||
tone: Annotated[Tone, Body()] = Tone.DEFAULT,
|
||||
verbosity: Annotated[Verbosity, Body()] = Verbosity.STANDARD,
|
||||
|
|
@ -138,23 +207,37 @@ async def create_presentation(
|
|||
include_table_of_contents: Annotated[bool, Body()] = False,
|
||||
include_title_slide: Annotated[bool, Body()] = True,
|
||||
web_search: Annotated[bool, Body()] = False,
|
||||
theme: Annotated[Optional[dict], Body()] = None,
|
||||
sql_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
|
||||
if include_table_of_contents and n_slides < 3:
|
||||
if n_slides is not None and n_slides < 1:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Number of slides must be greater than 0",
|
||||
)
|
||||
|
||||
if n_slides is not None and n_slides > MAX_NUMBER_OF_SLIDES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Number of slides cannot be greater than {MAX_NUMBER_OF_SLIDES}",
|
||||
)
|
||||
|
||||
if include_table_of_contents and n_slides is not None and n_slides < 3:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Number of slides cannot be less than 3 if table of contents is included",
|
||||
)
|
||||
|
||||
presentation_id = uuid.uuid4()
|
||||
language_to_store = (language or "").strip()
|
||||
# DB schema stores an int; 0 is used as internal marker for auto slide count.
|
||||
n_slides_to_store = n_slides if n_slides is not None else 0
|
||||
|
||||
presentation = PresentationModel(
|
||||
id=presentation_id,
|
||||
content=content,
|
||||
n_slides=n_slides,
|
||||
language=language,
|
||||
n_slides=n_slides_to_store,
|
||||
language=language_to_store,
|
||||
file_paths=file_paths,
|
||||
tone=tone.value,
|
||||
verbosity=verbosity.value,
|
||||
|
|
@ -162,7 +245,6 @@ async def create_presentation(
|
|||
include_table_of_contents=include_table_of_contents,
|
||||
include_title_slide=include_title_slide,
|
||||
web_search=web_search,
|
||||
theme=theme,
|
||||
)
|
||||
|
||||
sql_session.add(presentation)
|
||||
|
|
@ -212,40 +294,24 @@ async def prepare_presentation(
|
|||
presentation_structure.slides[index] = random_slide_index
|
||||
|
||||
if presentation.include_table_of_contents:
|
||||
n_toc_slides = presentation.n_slides - total_outlines
|
||||
n_toc_slides = get_no_of_toc_required_for_n_outlines(
|
||||
n_outlines=total_outlines,
|
||||
title_slide=presentation.include_title_slide,
|
||||
target_total_slides=(presentation.n_slides if presentation.n_slides > 0 else None),
|
||||
)
|
||||
toc_slide_layout_index = select_toc_or_list_slide_layout_index(layout)
|
||||
if toc_slide_layout_index != -1:
|
||||
outline_index = 1 if presentation.include_title_slide else 0
|
||||
for i in range(n_toc_slides):
|
||||
outlines_to = outline_index + 10
|
||||
if total_outlines == outlines_to:
|
||||
outlines_to -= 1
|
||||
|
||||
presentation_structure.slides.insert(
|
||||
i + 1 if presentation.include_title_slide else i,
|
||||
toc_slide_layout_index,
|
||||
)
|
||||
toc_outline = "Table of Contents\n\n"
|
||||
|
||||
for outline in presentation_outline_model.slides[
|
||||
outline_index:outlines_to
|
||||
]:
|
||||
page_number = (
|
||||
outline_index - i + n_toc_slides + 1
|
||||
if presentation.include_title_slide
|
||||
else outline_index - i + n_toc_slides
|
||||
)
|
||||
toc_outline += f"Slide page number: {page_number}\n Slide Content: {outline.content[:100]}\n\n"
|
||||
outline_index += 1
|
||||
|
||||
outline_index += 1
|
||||
|
||||
presentation_outline_model.slides.insert(
|
||||
i + 1 if presentation.include_title_slide else i,
|
||||
SlideOutlineModel(
|
||||
content=toc_outline,
|
||||
),
|
||||
)
|
||||
_insert_toc_layouts(
|
||||
presentation_structure,
|
||||
n_toc_slides,
|
||||
presentation.include_title_slide,
|
||||
toc_slide_layout_index,
|
||||
)
|
||||
if toc_slide_layout_index != -1 and n_toc_slides > 0:
|
||||
presentation_outline_model = get_presentation_outline_model_with_toc(
|
||||
outline=presentation_outline_model,
|
||||
n_toc_slides=n_toc_slides,
|
||||
title_slide=presentation.include_title_slide,
|
||||
)
|
||||
|
||||
sql_session.add(presentation)
|
||||
presentation.outlines = presentation_outline_model.model_dump(mode="json")
|
||||
|
|
@ -281,6 +347,7 @@ async def stream_presentation(
|
|||
structure = presentation.get_structure()
|
||||
layout = presentation.get_layout()
|
||||
outline = presentation.get_presentation_outline()
|
||||
image_urls_for_slides = get_images_for_slides_from_outline(outline.slides)
|
||||
|
||||
# These tasks will be gathered and awaited after all slides are generated
|
||||
async_assets_generation_tasks = []
|
||||
|
|
@ -321,7 +388,17 @@ async def stream_presentation(
|
|||
|
||||
# This will mutate slide - start task immediately so it runs in parallel with next slide LLM generation
|
||||
async_assets_generation_tasks.append(
|
||||
asyncio.create_task(process_slide_and_fetch_assets(image_generation_service, slide))
|
||||
asyncio.create_task(
|
||||
process_slide_and_fetch_assets(
|
||||
image_generation_service,
|
||||
slide,
|
||||
outline_image_urls=(
|
||||
image_urls_for_slides[i]
|
||||
if i < len(image_urls_for_slides)
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
yield SSEResponse(
|
||||
|
|
@ -353,6 +430,7 @@ async def stream_presentation(
|
|||
response = PresentationWithSlides(
|
||||
**presentation.model_dump(),
|
||||
slides=slides,
|
||||
fonts=await _resolve_presentation_fonts(presentation, slides, sql_session),
|
||||
)
|
||||
|
||||
yield SSECompleteResponse(
|
||||
|
|
@ -365,7 +443,6 @@ async def stream_presentation(
|
|||
|
||||
@PRESENTATION_ROUTER.patch("/update", response_model=PresentationWithSlides)
|
||||
async def update_presentation(
|
||||
request: Request,
|
||||
id: Annotated[uuid.UUID, Body()],
|
||||
n_slides: Annotated[Optional[int], Body()] = None,
|
||||
title: Annotated[Optional[str], Body()] = None,
|
||||
|
|
@ -378,18 +455,15 @@ async def update_presentation(
|
|||
raise HTTPException(status_code=404, detail="Presentation not found")
|
||||
|
||||
presentation_update_dict = {}
|
||||
request_body = await request.json()
|
||||
theme_provided = "theme" in request_body
|
||||
if n_slides:
|
||||
if n_slides is not None:
|
||||
presentation_update_dict["n_slides"] = n_slides
|
||||
if title:
|
||||
presentation_update_dict["title"] = title
|
||||
if theme_provided:
|
||||
if theme or theme is None:
|
||||
presentation_update_dict["theme"] = theme
|
||||
|
||||
if n_slides or title or theme_provided:
|
||||
if presentation_update_dict:
|
||||
presentation.sqlmodel_update(presentation_update_dict)
|
||||
|
||||
if slides:
|
||||
# Just to make sure id is UUID
|
||||
for slide in slides:
|
||||
|
|
@ -403,9 +477,17 @@ async def update_presentation(
|
|||
|
||||
await sql_session.commit()
|
||||
|
||||
response_slides = slides or []
|
||||
fonts = await _resolve_presentation_fonts(
|
||||
presentation,
|
||||
response_slides,
|
||||
sql_session,
|
||||
)
|
||||
|
||||
return PresentationWithSlides(
|
||||
**presentation.model_dump(),
|
||||
slides=slides or [],
|
||||
slides=response_slides,
|
||||
fonts=fonts,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -435,6 +517,11 @@ async def export_presentation_as_pptx_or_pdf(
|
|||
] = "pptx",
|
||||
sql_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""
|
||||
Export a presentation as PPTX or PDF.
|
||||
This Api is used to export via the nextjs app i.e using the puppeteer to export the presentation.
|
||||
|
||||
"""
|
||||
presentation = await sql_session.get(PresentationModel, id)
|
||||
|
||||
if not presentation:
|
||||
|
|
@ -466,13 +553,28 @@ async def check_if_api_request_is_valid(
|
|||
detail="Either content or slides markdown or files is required to generate presentation",
|
||||
)
|
||||
|
||||
# Making sure number of slides is greater than 0
|
||||
if request.n_slides <= 0:
|
||||
if request.n_slides is not None and request.n_slides <= 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Number of slides must be greater than 0",
|
||||
)
|
||||
|
||||
if request.n_slides is not None and request.n_slides > MAX_NUMBER_OF_SLIDES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Number of slides cannot be greater than {MAX_NUMBER_OF_SLIDES}",
|
||||
)
|
||||
|
||||
if (
|
||||
request.include_table_of_contents
|
||||
and request.n_slides is not None
|
||||
and request.n_slides < 3
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Number of slides cannot be less than 3 if table of contents is included",
|
||||
)
|
||||
|
||||
# Checking if template is valid
|
||||
if request.template not in DEFAULT_TEMPLATES:
|
||||
request.template = request.template.lower()
|
||||
|
|
@ -503,6 +605,7 @@ async def generate_presentation_handler(
|
|||
):
|
||||
try:
|
||||
using_slides_markdown = False
|
||||
language_to_use = (request.language or "").strip() or None
|
||||
|
||||
if request.slides_markdown:
|
||||
using_slides_markdown = True
|
||||
|
|
@ -519,7 +622,10 @@ async def generate_presentation_handler(
|
|||
await sql_session.commit()
|
||||
|
||||
if request.files:
|
||||
documents_loader = DocumentsLoader(file_paths=request.files)
|
||||
documents_loader = DocumentsLoader(
|
||||
file_paths=request.files,
|
||||
presentation_language=request.language,
|
||||
)
|
||||
await documents_loader.load_documents()
|
||||
documents = documents_loader.documents
|
||||
if documents:
|
||||
|
|
@ -527,30 +633,27 @@ async def generate_presentation_handler(
|
|||
|
||||
# Finding number of slides to generate by considering table of contents
|
||||
n_slides_to_generate = request.n_slides
|
||||
if request.include_table_of_contents:
|
||||
needed_toc_count = math.ceil(
|
||||
(
|
||||
(request.n_slides - 1)
|
||||
if request.include_title_slide
|
||||
else request.n_slides
|
||||
if request.include_table_of_contents and request.n_slides is not None:
|
||||
n_slides_to_generate = (
|
||||
get_no_of_outlines_to_generate_for_n_slides(
|
||||
n_slides=request.n_slides,
|
||||
toc=True,
|
||||
title_slide=request.include_title_slide,
|
||||
)
|
||||
/ 10
|
||||
)
|
||||
n_slides_to_generate -= math.ceil(
|
||||
(request.n_slides - needed_toc_count) / 10
|
||||
)
|
||||
|
||||
presentation_outlines_text = ""
|
||||
async for chunk in generate_ppt_outline(
|
||||
request.content,
|
||||
n_slides_to_generate,
|
||||
request.language,
|
||||
language_to_use,
|
||||
additional_context,
|
||||
request.tone.value,
|
||||
request.verbosity.value,
|
||||
request.instructions,
|
||||
request.include_title_slide,
|
||||
request.web_search,
|
||||
request.include_table_of_contents,
|
||||
):
|
||||
|
||||
if isinstance(chunk, HTTPException):
|
||||
|
|
@ -571,7 +674,20 @@ async def generate_presentation_handler(
|
|||
presentation_outlines = PresentationOutlineModel(
|
||||
**presentation_outlines_json
|
||||
)
|
||||
total_outlines = n_slides_to_generate
|
||||
|
||||
if (
|
||||
n_slides_to_generate is not None
|
||||
and len(presentation_outlines.slides) != n_slides_to_generate
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"Failed to generate presentation outlines with requested "
|
||||
"number of slides. Please try again."
|
||||
),
|
||||
)
|
||||
|
||||
total_outlines = len(presentation_outlines.slides)
|
||||
|
||||
else:
|
||||
# Setting outlines to slides markdown
|
||||
|
|
@ -619,50 +735,42 @@ async def generate_presentation_handler(
|
|||
if presentation_structure.slides[index] >= total_slide_layouts:
|
||||
presentation_structure.slides[index] = random_slide_index
|
||||
|
||||
# Injecting table of contents to the presentation structure and outlines
|
||||
if request.include_table_of_contents and not using_slides_markdown:
|
||||
n_toc_slides = request.n_slides - total_outlines
|
||||
should_include_toc = (
|
||||
request.include_table_of_contents and not using_slides_markdown
|
||||
)
|
||||
if should_include_toc:
|
||||
n_toc_slides = get_no_of_toc_required_for_n_outlines(
|
||||
n_outlines=total_outlines,
|
||||
title_slide=request.include_title_slide,
|
||||
target_total_slides=request.n_slides,
|
||||
)
|
||||
toc_slide_layout_index = select_toc_or_list_slide_layout_index(layout_model)
|
||||
if toc_slide_layout_index != -1:
|
||||
outline_index = 1 if request.include_title_slide else 0
|
||||
for i in range(n_toc_slides):
|
||||
outlines_to = outline_index + 10
|
||||
if total_outlines == outlines_to:
|
||||
outlines_to -= 1
|
||||
_insert_toc_layouts(
|
||||
presentation_structure,
|
||||
n_toc_slides,
|
||||
request.include_title_slide,
|
||||
toc_slide_layout_index,
|
||||
)
|
||||
if toc_slide_layout_index != -1 and n_toc_slides > 0:
|
||||
presentation_outlines = get_presentation_outline_model_with_toc(
|
||||
outline=presentation_outlines,
|
||||
n_toc_slides=n_toc_slides,
|
||||
title_slide=request.include_title_slide,
|
||||
)
|
||||
|
||||
presentation_structure.slides.insert(
|
||||
i + 1 if request.include_title_slide else i,
|
||||
toc_slide_layout_index,
|
||||
)
|
||||
toc_outline = "Table of Contents\n\n"
|
||||
|
||||
for outline in presentation_outlines.slides[
|
||||
outline_index:outlines_to
|
||||
]:
|
||||
page_number = (
|
||||
outline_index - i + n_toc_slides + 1
|
||||
if request.include_title_slide
|
||||
else outline_index - i + n_toc_slides
|
||||
)
|
||||
toc_outline += f"Slide page number: {page_number}\n Slide Content: {outline.content[:100]}\n\n"
|
||||
outline_index += 1
|
||||
|
||||
outline_index += 1
|
||||
|
||||
presentation_outlines.slides.insert(
|
||||
i + 1 if request.include_title_slide else i,
|
||||
SlideOutlineModel(
|
||||
content=toc_outline,
|
||||
),
|
||||
)
|
||||
final_n_slides = request.n_slides
|
||||
if final_n_slides is None:
|
||||
final_n_slides = len(presentation_outlines.slides)
|
||||
|
||||
# Create PresentationModel
|
||||
presentation = PresentationModel(
|
||||
id=presentation_id,
|
||||
content=request.content,
|
||||
n_slides=request.n_slides,
|
||||
language=request.language,
|
||||
title=get_presentation_title_from_outlines(presentation_outlines),
|
||||
n_slides=final_n_slides,
|
||||
language=language_to_use or "",
|
||||
title=get_presentation_title_from_presentation_outline(
|
||||
presentation_outlines
|
||||
),
|
||||
outlines=presentation_outlines.model_dump(),
|
||||
layout=layout_model.model_dump(),
|
||||
structure=presentation_structure.model_dump(),
|
||||
|
|
@ -699,7 +807,7 @@ async def generate_presentation_handler(
|
|||
get_slide_content_from_type_and_outline(
|
||||
slide_layouts[i],
|
||||
presentation_outlines.slides[i],
|
||||
request.language,
|
||||
language_to_use,
|
||||
request.tone.value,
|
||||
request.verbosity.value,
|
||||
request.instructions,
|
||||
|
|
@ -724,10 +832,23 @@ async def generate_presentation_handler(
|
|||
slides.append(slide)
|
||||
batch_slides.append(slide)
|
||||
|
||||
if using_slides_markdown:
|
||||
image_urls_for_batch = get_images_for_slides_from_outline(
|
||||
presentation_outlines.slides[start:end]
|
||||
)
|
||||
else:
|
||||
image_urls_for_batch = [[] for _ in batch_slides]
|
||||
|
||||
# Start asset fetch tasks immediately so they run in parallel with next batch's LLM calls
|
||||
asset_tasks = [
|
||||
asyncio.create_task(process_slide_and_fetch_assets(image_generation_service, slide))
|
||||
for slide in batch_slides
|
||||
asyncio.create_task(
|
||||
process_slide_and_fetch_assets(
|
||||
image_generation_service,
|
||||
slide,
|
||||
outline_image_urls=image_urls_for_batch[offset],
|
||||
)
|
||||
)
|
||||
for offset, slide in enumerate(batch_slides)
|
||||
]
|
||||
async_assets_generation_tasks.extend(asset_tasks)
|
||||
|
||||
|
|
@ -819,6 +940,8 @@ async def generate_presentation_sync(
|
|||
return await generate_presentation_handler(
|
||||
request, presentation_id, None, sql_session
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
raise HTTPException(status_code=500, detail="Presentation generation failed")
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import copy
|
||||
import uuid
|
||||
from typing import Any, List, Optional
|
||||
|
||||
|
|
@ -67,7 +68,9 @@ def _read_themes_from_row(row: Optional[KeyValueSqlModel]) -> list[dict[str, Any
|
|||
return []
|
||||
value = row.value if isinstance(row.value, dict) else {}
|
||||
themes = value.get("themes", [])
|
||||
return themes if isinstance(themes, list) else []
|
||||
if not isinstance(themes, list):
|
||||
return []
|
||||
return copy.deepcopy(themes)
|
||||
|
||||
|
||||
async def _resolve_logo_url(
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ TEXT_MIME_TYPES = ["text/plain"]
|
|||
POWERPOINT_TYPES = [
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||
]
|
||||
# Alias used by font/PPTX validation helpers shared with the Electron server tree.
|
||||
PPTX_MIME_TYPES = POWERPOINT_TYPES
|
||||
WORD_TYPES = [
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
|
|
|
|||
|
|
@ -4,4 +4,4 @@ OPENAI_URL = "https://api.openai.com/v1"
|
|||
DEFAULT_OPENAI_MODEL = "gpt-4.1"
|
||||
DEFAULT_GOOGLE_MODEL = "models/gemini-2.5-flash"
|
||||
DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-20250514"
|
||||
DEFAULT_CODEX_MODEL = "gpt-5.4-mini"
|
||||
DEFAULT_CODEX_MODEL = "gpt-5.2-codex"
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
DEFAULT_TEMPLATES = ["general", "modern", "standard", "swift"]
|
||||
MAX_NUMBER_OF_SLIDES = 50
|
||||
|
|
|
|||
|
|
@ -171,10 +171,141 @@ SUPPORTED_GPT_OSS_MODELS = {
|
|||
),
|
||||
}
|
||||
|
||||
SUPPORTED_GEMMA4_MODELS = {
|
||||
"gemma4:latest": OllamaModelMetadata(
|
||||
label="Gemma 4:latest",
|
||||
value="gemma4:latest",
|
||||
size="9.6GB",
|
||||
),
|
||||
"gemma4:e2b": OllamaModelMetadata(
|
||||
label="Gemma 4:e2b",
|
||||
value="gemma4:e2b",
|
||||
size="7.2GB",
|
||||
),
|
||||
"gemma4:e4b": OllamaModelMetadata(
|
||||
label="Gemma 4:e4b",
|
||||
value="gemma4:e4b",
|
||||
size="9.6GB",
|
||||
),
|
||||
"gemma4:26b": OllamaModelMetadata(
|
||||
label="Gemma 4:26b",
|
||||
value="gemma4:26b",
|
||||
size="18GB",
|
||||
),
|
||||
"gemma4:31b": OllamaModelMetadata(
|
||||
label="Gemma 4:31b",
|
||||
value="gemma4:31b",
|
||||
size="20GB",
|
||||
),
|
||||
|
||||
# e2b variants
|
||||
"gemma4:e2b-it-q4_K_M": OllamaModelMetadata(
|
||||
label="Gemma 4:e2b-it-q4_K_M",
|
||||
value="gemma4:e2b-it-q4_K_M",
|
||||
size="7.2GB",
|
||||
),
|
||||
"gemma4:e2b-it-q8_0": OllamaModelMetadata(
|
||||
label="Gemma 4:e2b-it-q8_0",
|
||||
value="gemma4:e2b-it-q8_0",
|
||||
size="8.1GB",
|
||||
),
|
||||
"gemma4:e2b-it-bf16": OllamaModelMetadata(
|
||||
label="Gemma 4:e2b-it-bf16",
|
||||
value="gemma4:e2b-it-bf16",
|
||||
size="10GB",
|
||||
),
|
||||
|
||||
# e4b variants
|
||||
"gemma4:e4b-it-q4_K_M": OllamaModelMetadata(
|
||||
label="Gemma 4:e4b-it-q4_K_M",
|
||||
value="gemma4:e4b-it-q4_K_M",
|
||||
size="9.6GB",
|
||||
),
|
||||
"gemma4:e4b-it-q8_0": OllamaModelMetadata(
|
||||
label="Gemma 4:e4b-it-q8_0",
|
||||
value="gemma4:e4b-it-q8_0",
|
||||
size="12GB",
|
||||
),
|
||||
"gemma4:e4b-it-bf16": OllamaModelMetadata(
|
||||
label="Gemma 4:e4b-it-bf16",
|
||||
value="gemma4:e4b-it-bf16",
|
||||
size="16GB",
|
||||
),
|
||||
|
||||
# 26b variants
|
||||
"gemma4:26b-a4b-it-q4_K_M": OllamaModelMetadata(
|
||||
label="Gemma 4:26b-a4b-it-q4_K_M",
|
||||
value="gemma4:26b-a4b-it-q4_K_M",
|
||||
size="18GB",
|
||||
),
|
||||
"gemma4:26b-a4b-it-q8_0": OllamaModelMetadata(
|
||||
label="Gemma 4:26b-a4b-it-q8_0",
|
||||
value="gemma4:26b-a4b-it-q8_0",
|
||||
size="28GB",
|
||||
),
|
||||
|
||||
# 31b variants
|
||||
"gemma4:31b-it-q4_K_M": OllamaModelMetadata(
|
||||
label="Gemma 4:31b-it-q4_K_M",
|
||||
value="gemma4:31b-it-q4_K_M",
|
||||
size="20GB",
|
||||
),
|
||||
"gemma4:31b-it-q8_0": OllamaModelMetadata(
|
||||
label="Gemma 4:31b-it-q8_0",
|
||||
value="gemma4:31b-it-q8_0",
|
||||
size="34GB",
|
||||
),
|
||||
"gemma4:31b-it-bf16": OllamaModelMetadata(
|
||||
label="Gemma 4:31b-it-bf16",
|
||||
value="gemma4:31b-it-bf16",
|
||||
size="63GB",
|
||||
)
|
||||
}
|
||||
|
||||
SUPPORTED_QWEN35_MODELS = {
|
||||
"qwen3.5:latest": OllamaModelMetadata(
|
||||
label="Qwen 3.5:latest",
|
||||
value="qwen3.5:latest",
|
||||
size="6.6GB",
|
||||
),
|
||||
"qwen3.5:2b": OllamaModelMetadata(
|
||||
label="Qwen 3.5:2b",
|
||||
value="qwen3.5:2b",
|
||||
size="2.7GB",
|
||||
),
|
||||
"qwen3.5:4b": OllamaModelMetadata(
|
||||
label="Qwen 3.5:4b",
|
||||
value="qwen3.5:4b",
|
||||
size="3.4GB",
|
||||
),
|
||||
"qwen3.5:9b": OllamaModelMetadata(
|
||||
label="Qwen 3.5:9b",
|
||||
value="qwen3.5:9b",
|
||||
size="6.6GB",
|
||||
),
|
||||
"qwen3.5:27b": OllamaModelMetadata(
|
||||
label="Qwen 3.5:27b",
|
||||
value="qwen3.5:27b",
|
||||
size="17GB",
|
||||
),
|
||||
"qwen3.5:35b": OllamaModelMetadata(
|
||||
label="Qwen 3.5:35b",
|
||||
value="qwen3.5:35b",
|
||||
size="24GB",
|
||||
),
|
||||
"qwen3.5:122b": OllamaModelMetadata(
|
||||
label="Qwen 3.5:122b",
|
||||
value="qwen3.5:122b",
|
||||
size="81GB",
|
||||
)
|
||||
}
|
||||
|
||||
SUPPORTED_OLLAMA_MODELS = {
|
||||
**SUPPORTED_OLLAMA_MODELS,
|
||||
**SUPPORTED_GEMMA_MODELS,
|
||||
**SUPPORTED_DEEPSEEK_MODELS,
|
||||
**SUPPORTED_QWEN_MODELS,
|
||||
**SUPPORTED_GPT_OSS_MODELS,
|
||||
**SUPPORTED_GEMMA4_MODELS,
|
||||
**SUPPORTED_QWEN35_MODELS,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ from utils.get_env import get_migrate_database_on_startup_env
|
|||
|
||||
|
||||
LEGACY_BASELINE_REVISION = "00b3c27a13bc"
|
||||
# Revision before 95b5127e93cd (template_create_infos); used when DB has theme but not that table.
|
||||
REVISION_BEFORE_TEMPLATE_CREATE_INFO = "82abdbc476a7"
|
||||
|
||||
|
||||
async def migrate_database_on_startup() -> None:
|
||||
|
|
@ -49,6 +51,7 @@ def _run_migrations() -> None:
|
|||
database_url = _to_sync_database_url(database_url)
|
||||
|
||||
config.set_main_option("sqlalchemy.url", database_url)
|
||||
_repair_orphan_alembic_revision(config, database_url)
|
||||
_stamp_legacy_database_if_needed(config, database_url)
|
||||
|
||||
try:
|
||||
|
|
@ -62,6 +65,52 @@ def _run_migrations() -> None:
|
|||
raise
|
||||
|
||||
|
||||
def _repair_orphan_alembic_revision(config: Config, database_url: str) -> None:
|
||||
"""
|
||||
If alembic_version points at a revision id that no longer exists in alembic/versions
|
||||
(removed branch, old image, etc.), re-stamp from the live schema so upgrade can run.
|
||||
"""
|
||||
script = ScriptDirectory.from_config(config)
|
||||
known = {rev.revision for rev in script.walk_revisions()}
|
||||
heads = script.get_heads()
|
||||
if len(heads) != 1:
|
||||
return
|
||||
head = heads[0]
|
||||
|
||||
engine = create_engine(database_url)
|
||||
try:
|
||||
with engine.connect() as connection:
|
||||
inspector = inspect(connection)
|
||||
tables = set(inspector.get_table_names())
|
||||
if "alembic_version" not in tables:
|
||||
return
|
||||
version_num = connection.execute(
|
||||
text("SELECT version_num FROM alembic_version LIMIT 1")
|
||||
).scalar_one_or_none()
|
||||
if not version_num or version_num in known:
|
||||
return
|
||||
print(
|
||||
f"Alembic revision {version_num!r} is missing from the codebase; "
|
||||
"inferring applied migrations from schema and re-stamping.",
|
||||
flush=True,
|
||||
)
|
||||
target = _infer_revision_from_schema(inspector, tables, head)
|
||||
command.stamp(config, target)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _infer_revision_from_schema(inspector, tables: set[str], head_revision: str) -> str:
|
||||
"""Best-effort: map existing SQLite/Postgres schema to our linear migration chain."""
|
||||
if "template_create_infos" in tables:
|
||||
return head_revision
|
||||
if "presentations" in tables:
|
||||
cols = {c["name"] for c in inspector.get_columns("presentations")}
|
||||
if "theme" in cols:
|
||||
return REVISION_BEFORE_TEMPLATE_CREATE_INFO
|
||||
return LEGACY_BASELINE_REVISION
|
||||
|
||||
|
||||
def _stamp_legacy_database_if_needed(config: Config, database_url: str) -> None:
|
||||
"""
|
||||
If the DB has app tables but no migration reference in alembic_version,
|
||||
|
|
|
|||
|
|
@ -18,9 +18,13 @@ class GeneratePresentationRequest(BaseModel):
|
|||
default=Verbosity.STANDARD, description="How verbose the presentation should be"
|
||||
)
|
||||
web_search: bool = Field(default=False, description="Whether to enable web search")
|
||||
n_slides: int = Field(default=8, description="Number of slides to generate")
|
||||
language: str = Field(
|
||||
default="English", description="Language for the presentation"
|
||||
n_slides: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Number of slides to generate. If omitted, model auto-detects slide count.",
|
||||
)
|
||||
language: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Language for the presentation. If omitted, model auto-detects language.",
|
||||
)
|
||||
template: str = Field(
|
||||
default="general", description="Template to use for the presentation"
|
||||
|
|
|
|||
|
|
@ -8,3 +8,4 @@ class OllamaModelStatus(BaseModel):
|
|||
downloaded: Optional[int] = None
|
||||
status: str
|
||||
done: bool
|
||||
error: Optional[str] = None
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from enum import Enum
|
||||
from typing import Annotated, List, Literal, Optional
|
||||
from typing import Annotated, List, Literal, Optional, Union
|
||||
from annotated_types import Len
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Discriminator, field_validator
|
||||
from pptx.util import Pt
|
||||
from pptx.enum.text import PP_ALIGN
|
||||
from pptx.enum.shapes import MSO_AUTO_SHAPE_TYPE, MSO_CONNECTOR_TYPE
|
||||
|
|
@ -130,6 +130,16 @@ class PptxAutoShapeBoxModel(PptxShapeModel):
|
|||
text_wrap: bool = True
|
||||
border_radius: Optional[int] = None
|
||||
paragraphs: Optional[List[PptxParagraphModel]] = None
|
||||
|
||||
@field_validator('border_radius', mode='before')
|
||||
@classmethod
|
||||
def convert_border_radius_to_int(cls, v):
|
||||
"""Convert float border_radius values to int."""
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, float):
|
||||
return int(round(v))
|
||||
return v
|
||||
|
||||
|
||||
class PptxPictureBoxModel(PptxShapeModel):
|
||||
|
|
@ -143,6 +153,16 @@ class PptxPictureBoxModel(PptxShapeModel):
|
|||
shape: Optional[PptxBoxShapeEnum] = None
|
||||
object_fit: Optional[PptxObjectFitModel] = None
|
||||
picture: PptxPictureModel
|
||||
|
||||
@field_validator('border_radius', mode='before')
|
||||
@classmethod
|
||||
def convert_border_radius_list_to_int(cls, v):
|
||||
"""Convert float values in border_radius list to int."""
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, list):
|
||||
return [int(round(item)) if isinstance(item, float) else int(item) for item in v]
|
||||
return v
|
||||
|
||||
|
||||
class PptxConnectorModel(PptxShapeModel):
|
||||
|
|
@ -154,15 +174,22 @@ class PptxConnectorModel(PptxShapeModel):
|
|||
opacity: float = 1.0
|
||||
|
||||
|
||||
# Define a discriminated union for shapes
|
||||
PptxShapeUnion = Annotated[
|
||||
Union[
|
||||
PptxTextBoxModel,
|
||||
PptxAutoShapeBoxModel,
|
||||
PptxConnectorModel,
|
||||
PptxPictureBoxModel,
|
||||
],
|
||||
Discriminator("shape_type"),
|
||||
]
|
||||
|
||||
|
||||
class PptxSlideModel(BaseModel):
|
||||
background: Optional[PptxFillModel] = None
|
||||
note: Optional[str] = None
|
||||
shapes: List[
|
||||
PptxTextBoxModel
|
||||
| PptxAutoShapeBoxModel
|
||||
| PptxConnectorModel
|
||||
| PptxPictureBoxModel
|
||||
]
|
||||
shapes: List[PptxShapeUnion]
|
||||
|
||||
|
||||
class PptxPresentationModel(BaseModel):
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from typing import List, Optional
|
||||
from typing import Any, List, Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
|
|
@ -17,5 +17,6 @@ class PresentationWithSlides(BaseModel):
|
|||
updated_at: datetime
|
||||
tone: Optional[str] = None
|
||||
verbosity: Optional[str] = None
|
||||
theme: Optional[dict] = None
|
||||
slides: List[SlideModel]
|
||||
theme: Optional[dict] = None
|
||||
fonts: Optional[Any] = None
|
||||
|
|
|
|||
|
|
@ -1,11 +1,27 @@
|
|||
from datetime import datetime
|
||||
from typing import Optional
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import JSON, Column, DateTime
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
from utils.datetime_utils import get_current_utc_datetime
|
||||
from utils.get_env import get_app_data_directory_env, get_next_public_fast_api_env
|
||||
from utils.path_helpers import get_resource_path
|
||||
|
||||
|
||||
def _with_fastapi_origin(path: str) -> str:
|
||||
"""Prefix relative web paths with FastAPI origin when available."""
|
||||
if path.startswith("http://") or path.startswith("https://"):
|
||||
return path
|
||||
|
||||
fastapi_origin = (get_next_public_fast_api_env() or "").strip()
|
||||
if not fastapi_origin:
|
||||
return path
|
||||
|
||||
normalized_path = path if path.startswith("/") else f"/{path}"
|
||||
return f"{fastapi_origin.rstrip('/')}{normalized_path}"
|
||||
|
||||
|
||||
class ImageAsset(SQLModel, table=True):
|
||||
|
|
@ -18,12 +34,45 @@ class ImageAsset(SQLModel, table=True):
|
|||
is_uploaded: bool = Field(default=False)
|
||||
path: str
|
||||
extras: Optional[dict] = Field(sa_column=Column(JSON), default=None)
|
||||
|
||||
|
||||
@property
|
||||
def file_url(self) -> str:
|
||||
"""
|
||||
Non-Electron backend helper for parity with the Electron ImageAsset model.
|
||||
For now this simply returns the stored path, allowing frontends to use
|
||||
`image.file_url or image.path` without breaking development workflows.
|
||||
Returns a web path suitable for FastAPI static serving.
|
||||
- HTTP(S) URLs are returned as-is.
|
||||
- Files under APP_DATA are exposed under /app_data.
|
||||
- Files under the packaged static directory are exposed under /static.
|
||||
"""
|
||||
return self.path
|
||||
path = self.path
|
||||
|
||||
# Already an absolute web URL
|
||||
if path.startswith("http://") or path.startswith("https://"):
|
||||
return path
|
||||
|
||||
# Already a web path under known mounts
|
||||
if path.startswith("/app_data/") or path.startswith("/static/"):
|
||||
return _with_fastapi_origin(path)
|
||||
|
||||
# Normalize filesystem path
|
||||
real_path = os.path.realpath(path)
|
||||
|
||||
# Map APP_DATA files to /app_data/...
|
||||
app_data_dir = get_app_data_directory_env()
|
||||
if app_data_dir:
|
||||
app_data_dir_real = os.path.realpath(app_data_dir)
|
||||
if real_path.startswith(app_data_dir_real):
|
||||
rel = os.path.relpath(real_path, app_data_dir_real)
|
||||
rel_web = rel.replace(os.sep, "/")
|
||||
return _with_fastapi_origin(f"/app_data/{rel_web}")
|
||||
|
||||
# Map packaged static assets to /static/...
|
||||
static_root = get_resource_path("static")
|
||||
static_root_real = os.path.realpath(static_root)
|
||||
if real_path.startswith(static_root_real):
|
||||
rel = os.path.relpath(real_path, static_root_real)
|
||||
rel_web = rel.replace(os.sep, "/")
|
||||
return _with_fastapi_origin(f"/static/{rel_web}")
|
||||
|
||||
# Fallback: return the original path (may be absolute or relative);
|
||||
# frontend can decide how to handle unusual cases.
|
||||
return path
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ import uuid
|
|||
from sqlalchemy import JSON, Column, DateTime, String
|
||||
from sqlmodel import Boolean, Field, SQLModel
|
||||
|
||||
from models.presentation_layout import PresentationLayoutModel
|
||||
from models.presentation_outline_model import PresentationOutlineModel
|
||||
from models.presentation_structure_model import PresentationStructureModel
|
||||
from models.presentation_layout import PresentationLayoutModel
|
||||
from utils.datetime_utils import get_current_utc_datetime
|
||||
|
||||
|
||||
|
|
@ -35,13 +35,13 @@ class PresentationModel(SQLModel, table=True):
|
|||
)
|
||||
layout: Optional[dict] = Field(sa_column=Column(JSON), default=None)
|
||||
structure: Optional[dict] = Field(sa_column=Column(JSON), default=None)
|
||||
theme: Optional[dict] = Field(sa_column=Column(JSON), default=None)
|
||||
instructions: Optional[str] = Field(sa_column=Column(String), default=None)
|
||||
tone: Optional[str] = Field(sa_column=Column(String), default=None)
|
||||
verbosity: Optional[str] = Field(sa_column=Column(String), default=None)
|
||||
include_table_of_contents: bool = Field(sa_column=Column(Boolean), default=False)
|
||||
include_title_slide: bool = Field(sa_column=Column(Boolean), default=True)
|
||||
web_search: bool = Field(sa_column=Column(Boolean), default=False)
|
||||
theme: Optional[dict] = Field(sa_column=Column(JSON), default=None)
|
||||
|
||||
def get_new_presentation(self):
|
||||
return PresentationModel(
|
||||
|
|
@ -54,7 +54,6 @@ class PresentationModel(SQLModel, table=True):
|
|||
outlines=self.outlines,
|
||||
layout=self.layout,
|
||||
structure=self.structure,
|
||||
theme=self.theme,
|
||||
instructions=self.instructions,
|
||||
tone=self.tone,
|
||||
verbosity=self.verbosity,
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from typing import Optional
|
||||
import uuid
|
||||
from sqlalchemy import Column, DateTime, Text, JSON
|
||||
from sqlmodel import SQLModel, Field
|
||||
|
||||
from sqlalchemy import JSON, Column, DateTime, Text
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
from utils.datetime_utils import get_current_utc_datetime
|
||||
|
||||
|
||||
class PresentationLayoutCodeModel(SQLModel, table=True):
|
||||
"""Model for storing presentation layout codes"""
|
||||
|
||||
__tablename__ = "presentation_layout_codes"
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
|
|
@ -19,8 +18,10 @@ class PresentationLayoutCodeModel(SQLModel, table=True):
|
|||
layout_code: str = Field(
|
||||
sa_column=Column(Text), description="TSX/React component code for the layout"
|
||||
)
|
||||
fonts: Optional[List[str]] = Field(
|
||||
sa_column=Column(JSON), default=None, description="Optional list of font links"
|
||||
fonts: Optional[dict[str, str] | list[str]] = Field(
|
||||
default=None,
|
||||
sa_column=Column(JSON, nullable=True),
|
||||
description="Optional font metadata associated with the layout",
|
||||
)
|
||||
created_at: datetime = Field(
|
||||
sa_column=Column(
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ class SlideModel(SQLModel, table=True):
|
|||
layout: str
|
||||
index: int
|
||||
content: dict = Field(sa_column=Column(JSON))
|
||||
html_content: Optional[str]
|
||||
html_content: Optional[str] = None
|
||||
speaker_note: Optional[str] = None
|
||||
properties: Optional[dict] = Field(sa_column=Column(JSON))
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
from datetime import datetime
|
||||
from typing import Optional
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Column, DateTime
|
||||
from sqlmodel import SQLModel, Field
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
from utils.datetime_utils import get_current_utc_datetime
|
||||
|
||||
|
|
|
|||
25
servers/fastapi/models/sql/template_create_info.py
Normal file
25
servers/fastapi/models/sql/template_create_info.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import JSON, Column, DateTime
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
from utils.datetime_utils import get_current_utc_datetime
|
||||
|
||||
|
||||
class TemplateCreateInfoModel(SQLModel, table=True):
|
||||
__tablename__ = "template_create_infos"
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
fonts: dict[str, str] | None = Field(
|
||||
default=None,
|
||||
sa_column=Column(JSON, nullable=True),
|
||||
)
|
||||
pptx_url: str | None = Field(default=None)
|
||||
slide_htmls: list[str] = Field(sa_column=Column(JSON, nullable=False))
|
||||
slide_image_urls: list[str] = Field(sa_column=Column(JSON, nullable=False))
|
||||
created_at: datetime = Field(
|
||||
sa_column=Column(
|
||||
DateTime(timezone=True), nullable=False, default=get_current_utc_datetime
|
||||
)
|
||||
)
|
||||
|
|
@ -55,3 +55,6 @@ class UserConfig(BaseModel):
|
|||
CODEX_REFRESH_TOKEN: Optional[str] = None
|
||||
CODEX_TOKEN_EXPIRES: Optional[str] = None
|
||||
CODEX_ACCOUNT_ID: Optional[str] = None
|
||||
CODEX_USERNAME: Optional[str] = None
|
||||
CODEX_EMAIL: Optional[str] = None
|
||||
CODEX_IS_PRO: Optional[bool] = None
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
[build-system]
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "presenton-backend"
|
||||
version = "0.1.0"
|
||||
|
|
@ -14,6 +18,7 @@ dependencies = [
|
|||
"dirtyjson>=1.0.8",
|
||||
"docling>=2.43.0",
|
||||
"fastapi[standard]>=0.116.1",
|
||||
"fastembed-vectorstore>=0.5.2",
|
||||
"fastmcp>=2.11.0",
|
||||
"google-genai>=1.28.0",
|
||||
"nltk>=3.9.1",
|
||||
|
|
@ -34,4 +39,4 @@ url = "https://download.pytorch.org/whl/cpu"
|
|||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["api*", "enums*", "models*", "services*", "constants*", "utils*"]
|
||||
include = ["api*", "enums*", "models*", "services*", "constants*", "utils*", "templates*"]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import uvicorn
|
||||
import argparse
|
||||
import os
|
||||
|
||||
import uvicorn
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Run the FastAPI server")
|
||||
|
|
@ -11,7 +13,9 @@ if __name__ == "__main__":
|
|||
)
|
||||
args = parser.parse_args()
|
||||
reload = args.reload == "true"
|
||||
|
||||
host = "127.0.0.1"
|
||||
os.environ["FASTAPI_PUBLIC_URL"] = f"http://{host}:{args.port}"
|
||||
|
||||
uvicorn.run(
|
||||
"api.main:app",
|
||||
host="127.0.0.1",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from sqlalchemy.ext.asyncio import (
|
|||
async_sessionmaker,
|
||||
AsyncSession,
|
||||
)
|
||||
from sqlalchemy import text
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
from models.sql.async_presentation_generation_status import (
|
||||
|
|
@ -15,12 +14,15 @@ from models.sql.async_presentation_generation_status import (
|
|||
from models.sql.image_asset import ImageAsset
|
||||
from models.sql.key_value import KeyValueSqlModel
|
||||
from models.sql.ollama_pull_status import OllamaPullStatus
|
||||
from models.sql.presentation import PresentationModel
|
||||
from models.sql.slide import SlideModel
|
||||
from models.sql.presentation_layout_code import PresentationLayoutCodeModel
|
||||
from models.sql.presentation import PresentationModel
|
||||
from models.sql.template import TemplateModel
|
||||
from models.sql.template_create_info import TemplateCreateInfoModel
|
||||
from models.sql.slide import SlideModel
|
||||
from models.sql.webhook_subscription import WebhookSubscription
|
||||
from utils.db_utils import get_database_url_and_connect_args
|
||||
from utils.get_env import get_app_data_directory_env
|
||||
from utils.get_env import get_migrate_database_on_startup_env
|
||||
|
||||
|
||||
database_url, connect_args = get_database_url_and_connect_args()
|
||||
|
|
@ -34,8 +36,9 @@ async def get_async_session() -> AsyncGenerator[AsyncSession, None]:
|
|||
yield session
|
||||
|
||||
|
||||
# Container DB (Lives inside the container)
|
||||
container_db_url = "sqlite+aiosqlite:////app/container.db"
|
||||
# Container DB (Lives inside the app data directory)
|
||||
_app_data_dir = get_app_data_directory_env() or "/tmp/presenton"
|
||||
container_db_url = f"sqlite+aiosqlite:///{os.path.join(_app_data_dir, 'container.db')}"
|
||||
container_db_engine: AsyncEngine = create_async_engine(
|
||||
container_db_url, connect_args={"check_same_thread": False}
|
||||
)
|
||||
|
|
@ -51,28 +54,25 @@ async def get_container_db_async_session() -> AsyncGenerator[AsyncSession, None]
|
|||
|
||||
# Create Database and Tables
|
||||
async def create_db_and_tables():
|
||||
async with sql_engine.begin() as conn:
|
||||
await conn.run_sync(
|
||||
lambda sync_conn: SQLModel.metadata.create_all(
|
||||
sync_conn,
|
||||
tables=[
|
||||
PresentationModel.__table__,
|
||||
SlideModel.__table__,
|
||||
KeyValueSqlModel.__table__,
|
||||
ImageAsset.__table__,
|
||||
PresentationLayoutCodeModel.__table__,
|
||||
TemplateModel.__table__,
|
||||
WebhookSubscription.__table__,
|
||||
AsyncPresentationGenerationTaskModel.__table__,
|
||||
],
|
||||
should_run_alembic = get_migrate_database_on_startup_env() in ["true", "True"]
|
||||
if not should_run_alembic:
|
||||
async with sql_engine.begin() as conn:
|
||||
await conn.run_sync(
|
||||
lambda sync_conn: SQLModel.metadata.create_all(
|
||||
sync_conn,
|
||||
tables=[
|
||||
PresentationModel.__table__,
|
||||
SlideModel.__table__,
|
||||
KeyValueSqlModel.__table__,
|
||||
ImageAsset.__table__,
|
||||
PresentationLayoutCodeModel.__table__,
|
||||
TemplateCreateInfoModel.__table__,
|
||||
TemplateModel.__table__,
|
||||
WebhookSubscription.__table__,
|
||||
AsyncPresentationGenerationTaskModel.__table__,
|
||||
],
|
||||
)
|
||||
)
|
||||
)
|
||||
# Lightweight schema migration for existing DBs: ensure `presentations.theme` exists.
|
||||
if database_url.startswith("sqlite"):
|
||||
result = await conn.execute(text("PRAGMA table_info(presentations)"))
|
||||
column_names = {row[1] for row in result.fetchall()}
|
||||
if "theme" not in column_names:
|
||||
await conn.execute(text("ALTER TABLE presentations ADD COLUMN theme JSON"))
|
||||
|
||||
async with container_db_engine.begin() as conn:
|
||||
await conn.run_sync(
|
||||
|
|
|
|||
|
|
@ -1,56 +1,134 @@
|
|||
import asyncio
|
||||
import json
|
||||
import chromadb
|
||||
from chromadb.config import Settings
|
||||
from chromadb.utils.embedding_functions import ONNXMiniLM_L6_V2
|
||||
import os
|
||||
from fastembed_vectorstore import FastembedEmbeddingModel, FastembedVectorstore
|
||||
from utils.path_helpers import get_resource_path, get_writable_path
|
||||
|
||||
|
||||
class IconFinderService:
|
||||
def __init__(self):
|
||||
self.collection_name = "icons"
|
||||
self.client = chromadb.PersistentClient(
|
||||
path="chroma", settings=Settings(anonymized_telemetry=False)
|
||||
)
|
||||
print("Initializing icons collection...")
|
||||
self._initialize_icons_collection()
|
||||
print("Icons collection initialized.")
|
||||
self.model = FastembedEmbeddingModel.AllMiniLML6V2
|
||||
# Use writable path for cache since it needs to be modified
|
||||
self.cache_directory = get_writable_path("fastembed_cache")
|
||||
self.vectorstore = None
|
||||
self._initialized = False
|
||||
self._initialization_failed = False
|
||||
|
||||
def _initialize_icons_collection(self):
|
||||
self.embedding_function = ONNXMiniLM_L6_V2()
|
||||
self.embedding_function.DOWNLOAD_PATH = "chroma/models"
|
||||
self.embedding_function._download_model_if_not_exists()
|
||||
if self._initialized or self._initialization_failed:
|
||||
return
|
||||
|
||||
# Mark as initialized immediately to prevent repeated attempts
|
||||
self._initialized = True
|
||||
|
||||
print("Initializing icons collection...")
|
||||
|
||||
# Ensure cache directory exists
|
||||
try:
|
||||
self.collection = self.client.get_collection(
|
||||
self.collection_name, embedding_function=self.embedding_function
|
||||
os.makedirs(self.cache_directory, exist_ok=True)
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not create cache directory: {e}")
|
||||
self._initialization_failed = True
|
||||
return
|
||||
|
||||
try:
|
||||
# Try bundled vectorstore first (read-only location)
|
||||
bundled_vectorstore_path = get_resource_path("assets/icons-vectorstore.json")
|
||||
# Writable location for user-created vectorstore (directory + filename)
|
||||
writable_assets_dir = get_writable_path("assets")
|
||||
writable_vectorstore_path = os.path.join(
|
||||
writable_assets_dir, "icons-vectorstore.json"
|
||||
)
|
||||
except Exception:
|
||||
with open("assets/icons.json", "r") as f:
|
||||
icons = json.load(f)
|
||||
|
||||
documents = []
|
||||
ids = []
|
||||
|
||||
for i, each in enumerate(icons["icons"]):
|
||||
if each["name"].split("-")[-1] == "bold":
|
||||
doc_text = f"{each['name']} {each['tags']}"
|
||||
documents.append(doc_text)
|
||||
ids.append(each["name"])
|
||||
|
||||
if documents:
|
||||
self.collection = self.client.create_collection(
|
||||
name=self.collection_name,
|
||||
embedding_function=self.embedding_function,
|
||||
metadata={"hnsw:space": "cosine"},
|
||||
# Icons JSON should be in bundled assets
|
||||
icons_path = get_resource_path("assets/icons.json")
|
||||
|
||||
print(f"[IconFinder] Bundled vectorstore path: {bundled_vectorstore_path}")
|
||||
print(f"[IconFinder] Writable vectorstore path: {writable_vectorstore_path}")
|
||||
print(f"[IconFinder] Icons.json path: {icons_path}")
|
||||
print(f"[IconFinder] Cache directory: {self.cache_directory}")
|
||||
print(f"[IconFinder] Bundled vectorstore exists: {os.path.isfile(bundled_vectorstore_path)}")
|
||||
print(f"[IconFinder] Writable vectorstore exists: {os.path.isfile(writable_vectorstore_path)}")
|
||||
print(f"[IconFinder] Icons.json exists: {os.path.isfile(icons_path)}")
|
||||
|
||||
# Try to load from bundled location first, then writable location
|
||||
# Use os.path.isfile() instead of os.path.exists() to avoid loading directories
|
||||
vectorstore_path = None
|
||||
if os.path.isfile(bundled_vectorstore_path):
|
||||
vectorstore_path = bundled_vectorstore_path
|
||||
print(f"[IconFinder] Loading vectorstore from bundled location: {vectorstore_path}")
|
||||
elif os.path.isfile(writable_vectorstore_path):
|
||||
vectorstore_path = writable_vectorstore_path
|
||||
print(f"[IconFinder] Loading vectorstore from writable location: {vectorstore_path}")
|
||||
|
||||
if vectorstore_path:
|
||||
self.vectorstore = FastembedVectorstore.load(
|
||||
self.model, vectorstore_path, cache_directory=self.cache_directory
|
||||
)
|
||||
self.collection.add(documents=documents, ids=ids)
|
||||
print("[IconFinder] Vectorstore loaded successfully")
|
||||
elif os.path.isfile(icons_path):
|
||||
print(f"[IconFinder] Creating new vectorstore from {icons_path}")
|
||||
self.vectorstore = FastembedVectorstore(
|
||||
self.model, cache_directory=self.cache_directory
|
||||
)
|
||||
with open(icons_path, "r", encoding="utf-8") as f:
|
||||
icons = json.load(f)
|
||||
|
||||
documents = []
|
||||
|
||||
for each in icons["icons"]:
|
||||
if each["name"].split("-")[-1] == "bold":
|
||||
doc_text = f"{each['name']}||{each['tags']}"
|
||||
documents.append(doc_text)
|
||||
|
||||
if documents:
|
||||
print(f"[IconFinder] Embedding {len(documents)} icons...")
|
||||
success = self.vectorstore.embed_documents(documents)
|
||||
if success:
|
||||
print(f"[IconFinder] Successfully embedded {len(documents)} icons")
|
||||
# Save to writable location
|
||||
try:
|
||||
os.makedirs(os.path.dirname(writable_vectorstore_path), exist_ok=True)
|
||||
self.vectorstore.save(writable_vectorstore_path)
|
||||
print(f"[IconFinder] Vectorstore saved to {writable_vectorstore_path}")
|
||||
except Exception as e:
|
||||
print(f"[IconFinder] Warning: Could not save vectorstore: {e}")
|
||||
# Continue anyway - vectorstore is still usable in memory
|
||||
else:
|
||||
print(f"[IconFinder] Failed to embed icons")
|
||||
self._initialization_failed = True
|
||||
else:
|
||||
print(f"[IconFinder] No icons found to embed")
|
||||
self._initialization_failed = True
|
||||
else:
|
||||
print(f"[IconFinder] ERROR: Icons assets not found at {icons_path}")
|
||||
self._initialization_failed = True
|
||||
|
||||
if not self._initialization_failed:
|
||||
print("[IconFinder] Icons collection initialized successfully.")
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not initialize icon finder service: {e}")
|
||||
print(f"Error type: {type(e).__name__}")
|
||||
print("Icon search will be disabled.")
|
||||
self._initialization_failed = True
|
||||
# Keep vectorstore as None so search_icons returns empty results
|
||||
|
||||
async def search_icons(self, query: str, k: int = 1):
|
||||
result = await asyncio.to_thread(
|
||||
self.collection.query,
|
||||
query_texts=[query],
|
||||
n_results=k,
|
||||
)
|
||||
return [f"/static/icons/bold/{each}.svg" for each in result["ids"][0]]
|
||||
if not self._initialized and not self._initialization_failed:
|
||||
self._initialize_icons_collection()
|
||||
|
||||
if not self.vectorstore or self._initialization_failed:
|
||||
# Return empty list if vectorstore failed to initialize
|
||||
return []
|
||||
|
||||
try:
|
||||
result = await asyncio.to_thread(self.vectorstore.search, query, k)
|
||||
return [
|
||||
f"/static/icons/bold/{each[0].split('||')[0]}.svg"
|
||||
for each in result
|
||||
]
|
||||
except Exception as e:
|
||||
print(f"Icon search error: {e}")
|
||||
return []
|
||||
|
||||
|
||||
ICON_FINDER_SERVICE = IconFinderService()
|
||||
|
|
|
|||
|
|
@ -5,12 +5,14 @@ import os
|
|||
import aiohttp
|
||||
from fastapi import HTTPException
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
from openai import NOT_GIVEN, AsyncOpenAI
|
||||
from models.image_prompt import ImagePrompt
|
||||
from models.sql.image_asset import ImageAsset
|
||||
from utils.get_env import (
|
||||
get_dall_e_3_quality_env,
|
||||
get_gpt_image_1_5_quality_env,
|
||||
get_next_public_fast_api_env,
|
||||
get_pexels_api_key_env,
|
||||
)
|
||||
from utils.get_env import get_pixabay_api_key_env
|
||||
|
|
@ -58,6 +60,17 @@ class ImageGenerationService:
|
|||
def is_stock_provider_selected(self):
|
||||
return is_pixels_selected() or is_pixabay_selected()
|
||||
|
||||
def _to_frontend_url(self, path: str) -> str:
|
||||
if path.startswith("http://") or path.startswith("https://"):
|
||||
return path
|
||||
|
||||
fastapi_origin = (get_next_public_fast_api_env() or "").strip()
|
||||
if not fastapi_origin:
|
||||
return path
|
||||
|
||||
normalized_path = path if path.startswith("/") else f"/{path}"
|
||||
return f"{fastapi_origin.rstrip('/')}{normalized_path}"
|
||||
|
||||
async def generate_image(self, prompt: ImagePrompt) -> str | ImageAsset:
|
||||
"""
|
||||
Generates an image based on the provided prompt.
|
||||
|
|
@ -68,11 +81,11 @@ class ImageGenerationService:
|
|||
"""
|
||||
if self.is_image_generation_disabled:
|
||||
print("Image generation is disabled. Using placeholder image.")
|
||||
return "/static/images/placeholder.jpg"
|
||||
return self._to_frontend_url("/static/images/placeholder.jpg")
|
||||
|
||||
if not self.image_gen_func:
|
||||
print("No image generation function found. Using placeholder image.")
|
||||
return "/static/images/placeholder.jpg"
|
||||
return self._to_frontend_url("/static/images/placeholder.jpg")
|
||||
|
||||
image_prompt = prompt.get_image_prompt(
|
||||
with_theme=not self.is_stock_provider_selected()
|
||||
|
|
@ -98,11 +111,15 @@ class ImageGenerationService:
|
|||
"theme_prompt": prompt.theme_prompt,
|
||||
},
|
||||
)
|
||||
elif image_path.startswith("/app_data/") or image_path.startswith(
|
||||
"/static/"
|
||||
):
|
||||
return self._to_frontend_url(image_path)
|
||||
raise Exception(f"Image not found at {image_path}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error generating image: {e}")
|
||||
return "/static/images/placeholder.jpg"
|
||||
return self._to_frontend_url("/static/images/placeholder.jpg")
|
||||
|
||||
async def generate_image_openai(
|
||||
self, prompt: str, output_directory: str, model: str, quality: str
|
||||
|
|
@ -149,15 +166,45 @@ class ImageGenerationService:
|
|||
response = await asyncio.to_thread(
|
||||
client.models.generate_content,
|
||||
model=model,
|
||||
contents=[prompt],
|
||||
contents=prompt,
|
||||
config=types.GenerateContentConfig(
|
||||
response_modalities=["IMAGE"],
|
||||
),
|
||||
)
|
||||
|
||||
# Latest SDK docs expose images in response.parts.
|
||||
response_parts = getattr(response, "parts", None)
|
||||
if not response_parts and getattr(response, "candidates", None):
|
||||
first_candidate = response.candidates[0] if response.candidates else None
|
||||
content = (
|
||||
getattr(first_candidate, "content", None) if first_candidate else None
|
||||
)
|
||||
response_parts = getattr(content, "parts", None) if content else None
|
||||
|
||||
image_path = None
|
||||
for part in response.candidates[0].content.parts:
|
||||
for part in response_parts or []:
|
||||
if part.inline_data is not None:
|
||||
image = part.as_image()
|
||||
image_path = os.path.join(output_directory, f"{uuid.uuid4()}.jpg")
|
||||
image.save(image_path)
|
||||
mime_type = getattr(part.inline_data, "mime_type", "") or ""
|
||||
ext = (
|
||||
mime_type.split("/")[-1]
|
||||
if mime_type.startswith("image/")
|
||||
else "png"
|
||||
)
|
||||
image_path = os.path.join(output_directory, f"{uuid.uuid4()}.{ext}")
|
||||
if hasattr(part, "as_image"):
|
||||
part.as_image().save(image_path)
|
||||
else:
|
||||
# Backward-compatible fallback if helper method is unavailable.
|
||||
image_data = getattr(part.inline_data, "data", None)
|
||||
if image_data is None:
|
||||
continue
|
||||
image_bytes = (
|
||||
base64.b64decode(image_data)
|
||||
if isinstance(image_data, str)
|
||||
else image_data
|
||||
)
|
||||
with open(image_path, "wb") as image_file:
|
||||
image_file.write(image_bytes)
|
||||
|
||||
if not image_path:
|
||||
raise HTTPException(
|
||||
|
|
@ -169,9 +216,9 @@ class ImageGenerationService:
|
|||
async def generate_image_gemini_flash(
|
||||
self, prompt: str, output_directory: str
|
||||
) -> str:
|
||||
"""Generate image using Gemini Flash (gemini-2.5-flash-image-preview)."""
|
||||
"""Generate image using Gemini Flash (gemini-2.5-flash-image)."""
|
||||
return await self._generate_image_google(
|
||||
prompt, output_directory, "gemini-2.5-flash-image-preview"
|
||||
prompt, output_directory, "gemini-2.5-flash-image"
|
||||
)
|
||||
|
||||
async def generate_image_nanobanana_pro(
|
||||
|
|
@ -182,24 +229,92 @@ class ImageGenerationService:
|
|||
prompt, output_directory, "gemini-3-pro-image-preview"
|
||||
)
|
||||
|
||||
async def get_image_from_pexels(self, prompt: str) -> str:
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
response = await session.get(
|
||||
f"https://api.pexels.com/v1/search?query={prompt}&per_page=1",
|
||||
headers={"Authorization": f"{get_pexels_api_key_env()}"},
|
||||
)
|
||||
data = await response.json()
|
||||
image_url = data["photos"][0]["src"]["large"]
|
||||
return image_url
|
||||
async def get_image_from_pexels(
|
||||
self, prompt: str, api_key: str | None = None, limit: int = 1
|
||||
) -> str | list[str]:
|
||||
per_page = max(1, min(limit, 80))
|
||||
resolved_api_key = (api_key or get_pexels_api_key_env() or "").strip()
|
||||
|
||||
async def get_image_from_pixabay(self, prompt: str) -> str:
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
response = await session.get(
|
||||
f"https://pixabay.com/api/?key={get_pixabay_api_key_env()}&q={prompt}&image_type=photo&per_page=3"
|
||||
"https://api.pexels.com/v1/search",
|
||||
params={"query": prompt, "per_page": per_page},
|
||||
headers={"Authorization": resolved_api_key} if resolved_api_key else {},
|
||||
timeout=aiohttp.ClientTimeout(total=20),
|
||||
)
|
||||
|
||||
if response.status in {401, 403}:
|
||||
raise HTTPException(status_code=401, detail="Invalid Pexels API key")
|
||||
if response.status != 200:
|
||||
error_text = await response.text()
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Pexels request failed: {error_text}",
|
||||
)
|
||||
|
||||
data = await response.json()
|
||||
image_url = data["hits"][0]["largeImageURL"]
|
||||
return image_url
|
||||
photos = data.get("photos", [])
|
||||
image_urls = [
|
||||
photo.get("src", {}).get("large")
|
||||
for photo in photos
|
||||
if photo.get("src", {}).get("large")
|
||||
]
|
||||
|
||||
if limit <= 1:
|
||||
return image_urls[0] if image_urls else ""
|
||||
return image_urls[:limit]
|
||||
|
||||
async def get_image_from_pixabay(
|
||||
self, prompt: str, api_key: str | None = None, limit: int = 1
|
||||
) -> str | list[str]:
|
||||
per_page = max(3, min(limit, 200))
|
||||
resolved_api_key = (api_key or get_pixabay_api_key_env() or "").strip()
|
||||
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
response = await session.get(
|
||||
"https://pixabay.com/api/",
|
||||
params={
|
||||
"key": resolved_api_key,
|
||||
"q": prompt[:99],
|
||||
"image_type": "photo",
|
||||
"per_page": per_page,
|
||||
},
|
||||
timeout=aiohttp.ClientTimeout(total=20),
|
||||
)
|
||||
|
||||
if response.status in {401, 403}:
|
||||
error_text = await response.text()
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail=f"Invalid Pixabay API key: {error_text}",
|
||||
)
|
||||
if response.status == 400:
|
||||
error_text = await response.text()
|
||||
if "api key" in error_text.lower():
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail=f"Invalid Pixabay API key: {error_text}",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Pixabay request invalid: {error_text}",
|
||||
)
|
||||
if response.status != 200:
|
||||
error_text = await response.text()
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Pixabay request failed: {error_text}",
|
||||
)
|
||||
|
||||
data = await response.json()
|
||||
hits = data.get("hits", [])
|
||||
image_urls = [
|
||||
hit.get("largeImageURL") for hit in hits if hit.get("largeImageURL")
|
||||
]
|
||||
|
||||
if limit <= 1:
|
||||
return image_urls[0] if image_urls else ""
|
||||
return image_urls[:limit]
|
||||
|
||||
async def generate_image_comfyui(self, prompt: str, output_directory: str) -> str:
|
||||
"""
|
||||
|
|
@ -342,6 +457,8 @@ class ImageGenerationService:
|
|||
"Found 'Input Prompt', but no writable prompt string field was found directly or through linked nodes."
|
||||
)
|
||||
|
||||
|
||||
|
||||
async def _submit_comfyui_workflow(
|
||||
self, session: aiohttp.ClientSession, comfyui_url: str, workflow: dict
|
||||
) -> str:
|
||||
|
|
|
|||
|
|
@ -1823,7 +1823,7 @@ class LLMClient:
|
|||
"""
|
||||
client: AsyncOpenAI = self._client
|
||||
response_schema = response_format
|
||||
# Apply strict schema once at root (includes array "items" fix in ensure_strict_json_schema).
|
||||
# Apply strict schema once at root (includes array "items" fix at lines 135–155).
|
||||
if strict and depth == 0:
|
||||
response_schema = ensure_strict_json_schema(
|
||||
response_schema,
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ class LLMToolCallsHandler:
|
|||
self.dynamic_tools.append(tool)
|
||||
|
||||
match self.client.llm_provider:
|
||||
case LLMProvider.OPENAI | LLMProvider.OLLAMA | LLMProvider.CUSTOM | LLMProvider.CODEX:
|
||||
case LLMProvider.OPENAI | LLMProvider.OLLAMA | LLMProvider.CUSTOM:
|
||||
return self.parse_tool_openai(tool, strict)
|
||||
case LLMProvider.ANTHROPIC:
|
||||
return self.parse_tool_anthropic(tool)
|
||||
|
|
@ -63,7 +63,7 @@ class LLMToolCallsHandler:
|
|||
return self.parse_tool_google(tool)
|
||||
case _:
|
||||
raise ValueError(
|
||||
f"LLM provider must be one of: openai, anthropic, google, codex"
|
||||
f"LLM provider must be either openai, anthropic, or google"
|
||||
)
|
||||
|
||||
def parse_tool_openai(
|
||||
|
|
|
|||
1
servers/fastapi/templates/__init__.py
Normal file
1
servers/fastapi/templates/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
__all__ = []
|
||||
167
servers/fastapi/templates/font_utils.py
Normal file
167
servers/fastapi/templates/font_utils.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import asyncio
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Iterable
|
||||
|
||||
import aiohttp
|
||||
|
||||
_STYLE_TOKENS = {
|
||||
"italic",
|
||||
"italics",
|
||||
"ital",
|
||||
"oblique",
|
||||
"roman",
|
||||
"bolditalic",
|
||||
"bolditalics",
|
||||
"thin",
|
||||
"hairline",
|
||||
"extralight",
|
||||
"ultralight",
|
||||
"light",
|
||||
"demilight",
|
||||
"semilight",
|
||||
"book",
|
||||
"regular",
|
||||
"normal",
|
||||
"medium",
|
||||
"semibold",
|
||||
"demibold",
|
||||
"bold",
|
||||
"extrabold",
|
||||
"ultrabold",
|
||||
"black",
|
||||
"extrablack",
|
||||
"ultrablack",
|
||||
"heavy",
|
||||
"narrow",
|
||||
"condensed",
|
||||
"semicondensed",
|
||||
"extracondensed",
|
||||
"ultracondensed",
|
||||
"expanded",
|
||||
"semiexpanded",
|
||||
"extraexpanded",
|
||||
"ultraexpanded",
|
||||
}
|
||||
_STYLE_MODIFIERS = {"semi", "demi", "extra", "ultra"}
|
||||
|
||||
|
||||
def _insert_spaces_in_camel_case(value: str) -> str:
|
||||
value = re.sub(r"(?<=[a-z0-9])([A-Z])", r" \1", value)
|
||||
value = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", value)
|
||||
return value
|
||||
|
||||
|
||||
def normalize_font_family_name(raw_name: str) -> str:
|
||||
if not raw_name:
|
||||
return raw_name
|
||||
|
||||
name = raw_name.replace("_", " ").replace("-", " ")
|
||||
name = _insert_spaces_in_camel_case(name)
|
||||
name = re.sub(r"\s+", " ", name).strip()
|
||||
lower_name = name.lower()
|
||||
|
||||
for style in sorted(_STYLE_TOKENS, key=len, reverse=True):
|
||||
suffix = " " + style
|
||||
if lower_name.endswith(suffix):
|
||||
name = name[: -len(suffix)]
|
||||
lower_name = lower_name[: -len(suffix)]
|
||||
break
|
||||
|
||||
tokens_original = name.split(" ")
|
||||
tokens_filtered: list[str] = []
|
||||
for index, token in enumerate(tokens_original):
|
||||
lower_token = token.lower()
|
||||
if index == 0:
|
||||
tokens_filtered.append(token)
|
||||
continue
|
||||
if lower_token in _STYLE_TOKENS or lower_token in _STYLE_MODIFIERS:
|
||||
continue
|
||||
tokens_filtered.append(token)
|
||||
|
||||
if not tokens_filtered:
|
||||
tokens_filtered = tokens_original
|
||||
|
||||
return re.sub(r"\s+", " ", " ".join(tokens_filtered).strip())
|
||||
|
||||
|
||||
def extract_fonts_from_oxml(xml_content: str) -> list[str]:
|
||||
fonts = set()
|
||||
|
||||
try:
|
||||
root = ET.fromstring(xml_content)
|
||||
namespaces = {
|
||||
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
|
||||
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
|
||||
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
|
||||
}
|
||||
|
||||
for xpath in (".//a:latin", ".//a:ea", ".//a:cs", ".//a:font"):
|
||||
for font_elem in root.findall(xpath, namespaces):
|
||||
typeface = font_elem.attrib.get("typeface")
|
||||
if typeface:
|
||||
fonts.add(typeface)
|
||||
|
||||
for rpr_elem in root.findall(".//a:rPr", namespaces):
|
||||
for font_elem in rpr_elem.findall(".//a:latin", namespaces):
|
||||
typeface = font_elem.attrib.get("typeface")
|
||||
if typeface:
|
||||
fonts.add(typeface)
|
||||
|
||||
for font_elem in root.findall(".//latin"):
|
||||
typeface = font_elem.attrib.get("typeface")
|
||||
if typeface:
|
||||
fonts.add(typeface)
|
||||
|
||||
fonts.update(re.findall(r'typeface="([^"]+)"', xml_content))
|
||||
|
||||
system_fonts = {"+mn-lt", "+mj-lt", "+mn-ea", "+mj-ea", "+mn-cs", "+mj-cs", ""}
|
||||
return sorted(font for font in fonts if font not in system_fonts and font.strip())
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def get_google_font_css_url(font_name: str) -> str:
|
||||
return f"https://fonts.googleapis.com/css2?family={font_name.replace(' ', '+')}&display=swap"
|
||||
|
||||
|
||||
async def check_google_font_availability(font_name: str) -> bool:
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.head(
|
||||
get_google_font_css_url(font_name),
|
||||
timeout=aiohttp.ClientTimeout(total=10),
|
||||
) as response:
|
||||
return response.status == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def collect_normalized_fonts_from_xmls(slide_xmls: Iterable[str]) -> list[str]:
|
||||
raw_fonts = set()
|
||||
for xml_content in slide_xmls:
|
||||
raw_fonts.update(extract_fonts_from_oxml(xml_content))
|
||||
|
||||
normalized_fonts = {normalize_font_family_name(font) for font in raw_fonts}
|
||||
return sorted(font for font in normalized_fonts if font)
|
||||
|
||||
|
||||
async def get_available_and_unavailable_fonts(
|
||||
font_names: Iterable[str],
|
||||
) -> tuple[list[tuple[str, str]], list[tuple[str, None]]]:
|
||||
normalized_fonts = sorted({font for font in font_names if font})
|
||||
if not normalized_fonts:
|
||||
return [], []
|
||||
|
||||
results = await asyncio.gather(
|
||||
*[check_google_font_availability(font) for font in normalized_fonts]
|
||||
)
|
||||
|
||||
available_fonts: list[tuple[str, str]] = []
|
||||
unavailable_fonts: list[tuple[str, None]] = []
|
||||
for font_name, is_available in zip(normalized_fonts, results):
|
||||
if is_available:
|
||||
available_fonts.append((font_name, get_google_font_css_url(font_name)))
|
||||
else:
|
||||
unavailable_fonts.append((font_name, None))
|
||||
return available_fonts, unavailable_fonts
|
||||
477
servers/fastapi/templates/preview.py
Normal file
477
servers/fastapi/templates/preview.py
Normal file
|
|
@ -0,0 +1,477 @@
|
|||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from fastapi import File, HTTPException, UploadFile
|
||||
from pydantic import BaseModel
|
||||
|
||||
from constants.documents import PPTX_MIME_TYPES
|
||||
from services.documents_loader import DocumentsLoader
|
||||
from templates.font_utils import (
|
||||
collect_normalized_fonts_from_xmls,
|
||||
get_available_and_unavailable_fonts,
|
||||
)
|
||||
from utils.get_env import get_app_data_directory_env
|
||||
|
||||
try:
|
||||
from fontTools.ttLib import TTFont
|
||||
|
||||
FONTTOOLS_AVAILABLE = True
|
||||
except ImportError:
|
||||
FONTTOOLS_AVAILABLE = False
|
||||
|
||||
|
||||
SUPPORTED_FONT_EXTENSIONS = {
|
||||
".ttf": "font/ttf",
|
||||
".otf": "font/otf",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".eot": "application/vnd.ms-fontobject",
|
||||
}
|
||||
|
||||
|
||||
class FontInfo(BaseModel):
|
||||
name: str
|
||||
url: str | None = None
|
||||
|
||||
|
||||
class FontCheckResponse(BaseModel):
|
||||
available_fonts: List[FontInfo]
|
||||
unavailable_fonts: List[FontInfo]
|
||||
|
||||
|
||||
class FontsUploadAndSlidesPreviewResponse(BaseModel):
|
||||
slide_image_urls: List[str]
|
||||
pptx_url: str
|
||||
modified_pptx_url: str
|
||||
fonts: dict
|
||||
|
||||
|
||||
@dataclass
|
||||
class StoredFont:
|
||||
display_name: str
|
||||
url: str
|
||||
temp_path: str
|
||||
|
||||
|
||||
def _get_soffice_binary() -> str:
|
||||
configured = os.environ.get("SOFFICE_PATH")
|
||||
if configured:
|
||||
return configured
|
||||
return "soffice.exe" if os.name == "nt" else "soffice"
|
||||
|
||||
|
||||
def _windows_hidden_subprocess_kwargs() -> Dict[str, object]:
|
||||
if os.name != "nt":
|
||||
return {}
|
||||
|
||||
startupinfo = subprocess.STARTUPINFO()
|
||||
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
||||
return {
|
||||
"creationflags": getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
"startupinfo": startupinfo,
|
||||
}
|
||||
|
||||
|
||||
def _app_data_directory() -> str:
|
||||
app_data_dir = get_app_data_directory_env() or "/tmp/presenton"
|
||||
os.makedirs(app_data_dir, exist_ok=True)
|
||||
return app_data_dir
|
||||
|
||||
|
||||
def _get_fonts_directory() -> str:
|
||||
fonts_dir = os.path.join(_app_data_directory(), "fonts")
|
||||
os.makedirs(fonts_dir, exist_ok=True)
|
||||
return fonts_dir
|
||||
|
||||
|
||||
def _get_images_directory() -> str:
|
||||
images_dir = os.path.join(_app_data_directory(), "images")
|
||||
os.makedirs(images_dir, exist_ok=True)
|
||||
return images_dir
|
||||
|
||||
|
||||
def _get_template_uploads_directory() -> str:
|
||||
uploads_dir = os.path.join(_app_data_directory(), "uploads", "template-previews")
|
||||
os.makedirs(uploads_dir, exist_ok=True)
|
||||
return uploads_dir
|
||||
|
||||
|
||||
def _write_bytes_to_path(path: str, data: bytes) -> None:
|
||||
with open(path, "wb") as file:
|
||||
file.write(data)
|
||||
|
||||
|
||||
def _copy_file(source_path: str, destination_path: str) -> None:
|
||||
shutil.copy2(source_path, destination_path)
|
||||
|
||||
|
||||
def _extract_font_name_from_file(file_path: str) -> str:
|
||||
filename = os.path.basename(file_path)
|
||||
base_name = os.path.splitext(filename)[0]
|
||||
if not FONTTOOLS_AVAILABLE:
|
||||
return base_name
|
||||
|
||||
try:
|
||||
font = TTFont(file_path)
|
||||
if "name" in font:
|
||||
name_table = font["name"]
|
||||
for name_id in (1, 4, 6):
|
||||
for record in name_table.names:
|
||||
if record.nameID != name_id:
|
||||
continue
|
||||
if record.langID in (0x409, 0):
|
||||
font_name = record.toUnicode().strip()
|
||||
if font_name:
|
||||
font.close()
|
||||
return font_name
|
||||
for record in name_table.names:
|
||||
if record.nameID != 1:
|
||||
continue
|
||||
font_name = record.toUnicode().strip()
|
||||
if font_name:
|
||||
font.close()
|
||||
return font_name
|
||||
font.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return base_name
|
||||
|
||||
|
||||
def _validate_pptx_file(pptx_file: UploadFile) -> None:
|
||||
filename = getattr(pptx_file, "filename", "") or ""
|
||||
if not filename.lower().endswith(".pptx"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid file type. Expected PPTX file",
|
||||
)
|
||||
if pptx_file.content_type and pptx_file.content_type not in PPTX_MIME_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid file type. Expected PPTX file, got {pptx_file.content_type}",
|
||||
)
|
||||
|
||||
|
||||
def _ensure_valid_font_file(font_file: UploadFile) -> None:
|
||||
filename = font_file.filename or ""
|
||||
extension = os.path.splitext(filename)[1].lower()
|
||||
if extension not in SUPPORTED_FONT_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid font file. Supported formats: {', '.join(SUPPORTED_FONT_EXTENSIONS.keys())}",
|
||||
)
|
||||
|
||||
|
||||
async def _persist_custom_fonts(
|
||||
font_files: Optional[List[UploadFile]],
|
||||
original_font_names: Optional[List[str]],
|
||||
temp_dir: str,
|
||||
) -> list[StoredFont]:
|
||||
if not font_files:
|
||||
return []
|
||||
|
||||
stored_fonts: list[StoredFont] = []
|
||||
fonts_dir = _get_fonts_directory()
|
||||
|
||||
for index, font_file in enumerate(font_files):
|
||||
_ensure_valid_font_file(font_file)
|
||||
|
||||
original_name = (
|
||||
original_font_names[index]
|
||||
if original_font_names and index < len(original_font_names)
|
||||
else None
|
||||
)
|
||||
extension = os.path.splitext(font_file.filename or "")[1].lower()
|
||||
unique_name = f"{Path(font_file.filename or f'font_{index}').stem}_{uuid.uuid4().hex[:8]}{extension}"
|
||||
temp_font_path = os.path.join(temp_dir, unique_name)
|
||||
permanent_font_path = os.path.join(fonts_dir, unique_name)
|
||||
font_bytes = await font_file.read()
|
||||
|
||||
await asyncio.to_thread(_write_bytes_to_path, temp_font_path, font_bytes)
|
||||
await asyncio.to_thread(_write_bytes_to_path, permanent_font_path, font_bytes)
|
||||
|
||||
actual_font_name = await asyncio.to_thread(
|
||||
_extract_font_name_from_file, permanent_font_path
|
||||
)
|
||||
display_name = original_name or actual_font_name
|
||||
stored_fonts.append(
|
||||
StoredFont(
|
||||
display_name=display_name,
|
||||
url=f"/app_data/fonts/{unique_name}",
|
||||
temp_path=temp_font_path,
|
||||
)
|
||||
)
|
||||
|
||||
return stored_fonts
|
||||
|
||||
|
||||
def _create_font_alias_config(raw_fonts: List[str]) -> str:
|
||||
mappings: Dict[str, str] = {}
|
||||
for font_name in raw_fonts:
|
||||
normalized = font_name
|
||||
if not normalized:
|
||||
continue
|
||||
mappings[font_name] = normalized
|
||||
|
||||
fd, fonts_conf_path = tempfile.mkstemp(prefix="fonts_alias_", suffix=".conf")
|
||||
os.close(fd)
|
||||
with open(fonts_conf_path, "w", encoding="utf-8") as cfg:
|
||||
cfg.write(
|
||||
"""<?xml version='1.0'?>
|
||||
<!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">
|
||||
<fontconfig>
|
||||
<include>/etc/fonts/fonts.conf</include>
|
||||
"""
|
||||
)
|
||||
for source_family, destination_family in mappings.items():
|
||||
if source_family == destination_family:
|
||||
continue
|
||||
cfg.write(
|
||||
f"""
|
||||
<match target="pattern">
|
||||
<test name="family" compare="eq">
|
||||
<string>{source_family}</string>
|
||||
</test>
|
||||
<edit name="family" mode="assign" binding="strong">
|
||||
<string>{destination_family}</string>
|
||||
</edit>
|
||||
</match>
|
||||
"""
|
||||
)
|
||||
cfg.write("\n</fontconfig>\n")
|
||||
return fonts_conf_path
|
||||
|
||||
|
||||
async def _install_fonts(font_paths: List[str]) -> None:
|
||||
if not font_paths:
|
||||
return
|
||||
|
||||
for font_path in font_paths:
|
||||
try:
|
||||
subprocess.run(
|
||||
["cp", font_path, "/usr/share/fonts/truetype/"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
continue
|
||||
|
||||
try:
|
||||
subprocess.run(["fc-cache", "-f", "-v"], check=True, capture_output=True)
|
||||
except subprocess.CalledProcessError:
|
||||
pass
|
||||
|
||||
|
||||
def extract_slide_xmls(pptx_path: str, temp_dir: str) -> List[str]:
|
||||
slide_xmls: list[str] = []
|
||||
extract_dir = os.path.join(temp_dir, "pptx_extract")
|
||||
|
||||
with zipfile.ZipFile(pptx_path, "r") as zip_ref:
|
||||
zip_ref.extractall(extract_dir)
|
||||
|
||||
slides_dir = os.path.join(extract_dir, "ppt", "slides")
|
||||
if not os.path.exists(slides_dir):
|
||||
raise HTTPException(status_code=400, detail="No slides directory found in PPTX")
|
||||
|
||||
slide_files = [
|
||||
file_name
|
||||
for file_name in os.listdir(slides_dir)
|
||||
if file_name.startswith("slide") and file_name.endswith(".xml")
|
||||
]
|
||||
slide_files.sort(key=lambda value: int(re.sub(r"[^0-9]", "", value) or "0"))
|
||||
|
||||
for slide_file in slide_files:
|
||||
slide_path = os.path.join(slides_dir, slide_file)
|
||||
with open(slide_path, "r", encoding="utf-8") as slide_handle:
|
||||
slide_xmls.append(slide_handle.read())
|
||||
|
||||
return slide_xmls
|
||||
|
||||
|
||||
async def convert_pptx_to_pdf(
|
||||
pptx_path: str,
|
||||
temp_dir: str,
|
||||
slide_xmls: Optional[List[str]] = None,
|
||||
) -> str:
|
||||
screenshots_dir = os.path.join(temp_dir, "screenshots")
|
||||
os.makedirs(screenshots_dir, exist_ok=True)
|
||||
|
||||
slide_xmls = slide_xmls or extract_slide_xmls(pptx_path, temp_dir)
|
||||
raw_fonts = collect_normalized_fonts_from_xmls(slide_xmls)
|
||||
fonts_conf_path = _create_font_alias_config(raw_fonts)
|
||||
env = os.environ.copy()
|
||||
env["FONTCONFIG_FILE"] = fonts_conf_path
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
_get_soffice_binary(),
|
||||
"--headless",
|
||||
"--convert-to",
|
||||
"pdf",
|
||||
"--outdir",
|
||||
screenshots_dir,
|
||||
pptx_path,
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=500,
|
||||
env=env,
|
||||
**_windows_hidden_subprocess_kwargs(),
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="LibreOffice PDF conversion timed out after 500 seconds",
|
||||
) from exc
|
||||
except subprocess.CalledProcessError as exc:
|
||||
error_message = exc.stderr if exc.stderr else str(exc)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"LibreOffice PDF conversion failed: {error_message}",
|
||||
) from exc
|
||||
|
||||
pdf_files = [file_name for file_name in os.listdir(screenshots_dir) if file_name.endswith(".pdf")]
|
||||
if not pdf_files:
|
||||
raise HTTPException(
|
||||
status_code=500, detail="LibreOffice failed to generate a PDF file"
|
||||
)
|
||||
|
||||
return os.path.join(screenshots_dir, pdf_files[0])
|
||||
|
||||
|
||||
async def store_slide_images(
|
||||
screenshot_paths: List[str],
|
||||
session_id: uuid.UUID,
|
||||
) -> List[str]:
|
||||
images_dir = _get_images_directory()
|
||||
target_dir = os.path.join(images_dir, str(session_id))
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
|
||||
slide_image_urls: list[str] = []
|
||||
for index, screenshot_path in enumerate(screenshot_paths, start=1):
|
||||
file_name = f"slide_{index}.png"
|
||||
destination_path = os.path.join(target_dir, file_name)
|
||||
|
||||
if os.path.exists(screenshot_path) and os.path.getsize(screenshot_path) > 0:
|
||||
await asyncio.to_thread(_copy_file, screenshot_path, destination_path)
|
||||
slide_image_urls.append(f"/app_data/images/{session_id}/{file_name}")
|
||||
else:
|
||||
slide_image_urls.append("/static/images/placeholder.jpg")
|
||||
|
||||
return slide_image_urls
|
||||
|
||||
|
||||
async def store_uploaded_pptx(
|
||||
pptx_path: str,
|
||||
session_id: uuid.UUID,
|
||||
) -> str:
|
||||
uploads_dir = _get_template_uploads_directory()
|
||||
target_dir = os.path.join(uploads_dir, str(session_id))
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
|
||||
destination_path = os.path.join(target_dir, "presentation.pptx")
|
||||
await asyncio.to_thread(_copy_file, pptx_path, destination_path)
|
||||
return f"/app_data/uploads/template-previews/{session_id}/presentation.pptx"
|
||||
|
||||
|
||||
async def get_available_and_unavailable_fonts_for_pptx(
|
||||
pptx_path: str, temp_dir: str
|
||||
) -> tuple[list[tuple[str, str]], list[tuple[str, None]]]:
|
||||
slide_xmls = extract_slide_xmls(pptx_path, temp_dir)
|
||||
normalized_fonts = collect_normalized_fonts_from_xmls(slide_xmls)
|
||||
return await get_available_and_unavailable_fonts(normalized_fonts)
|
||||
|
||||
|
||||
async def check_fonts_in_pptx_handler(
|
||||
pptx_file: UploadFile = File(..., description="PPTX file to analyze fonts from")
|
||||
) -> FontCheckResponse:
|
||||
_validate_pptx_file(pptx_file)
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
pptx_path = os.path.join(temp_dir, "presentation.pptx")
|
||||
pptx_content = await pptx_file.read()
|
||||
await asyncio.to_thread(_write_bytes_to_path, pptx_path, pptx_content)
|
||||
|
||||
available_fonts_data, unavailable_fonts_data = (
|
||||
await get_available_and_unavailable_fonts_for_pptx(pptx_path, temp_dir)
|
||||
)
|
||||
|
||||
return FontCheckResponse(
|
||||
available_fonts=[
|
||||
FontInfo(name=name, url=url) for name, url in available_fonts_data
|
||||
],
|
||||
unavailable_fonts=[
|
||||
FontInfo(name=name, url=url) for name, url in unavailable_fonts_data
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
async def upload_fonts_and_slides_preview_handler(
|
||||
pptx_file: UploadFile,
|
||||
font_files: Optional[List[UploadFile]] = None,
|
||||
original_font_names: Optional[List[str]] = None,
|
||||
max_slides: Optional[int] = None,
|
||||
) -> FontsUploadAndSlidesPreviewResponse:
|
||||
if (font_files and not original_font_names) or (
|
||||
original_font_names and not font_files
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Both font_files and original_font_names must be provided together",
|
||||
)
|
||||
if font_files and original_font_names and len(font_files) != len(original_font_names):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Number of font files must match number of original font names",
|
||||
)
|
||||
|
||||
_validate_pptx_file(pptx_file)
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
pptx_path = os.path.join(temp_dir, "presentation.pptx")
|
||||
pptx_content = await pptx_file.read()
|
||||
await asyncio.to_thread(_write_bytes_to_path, pptx_path, pptx_content)
|
||||
|
||||
stored_fonts = await _persist_custom_fonts(
|
||||
font_files=font_files,
|
||||
original_font_names=original_font_names,
|
||||
temp_dir=temp_dir,
|
||||
)
|
||||
await _install_fonts([font.temp_path for font in stored_fonts])
|
||||
|
||||
slide_xmls = extract_slide_xmls(pptx_path, temp_dir)
|
||||
pdf_path = await convert_pptx_to_pdf(pptx_path, temp_dir, slide_xmls=slide_xmls)
|
||||
screenshot_paths = await DocumentsLoader.get_page_images_from_pdf_async(
|
||||
pdf_path, temp_dir
|
||||
)
|
||||
|
||||
if max_slides and len(screenshot_paths) > max_slides:
|
||||
screenshot_paths = screenshot_paths[:max_slides]
|
||||
|
||||
session_id = uuid.uuid4()
|
||||
slide_image_urls = await store_slide_images(screenshot_paths, session_id)
|
||||
pptx_url = await store_uploaded_pptx(pptx_path, session_id)
|
||||
|
||||
available_fonts, _ = await get_available_and_unavailable_fonts(
|
||||
collect_normalized_fonts_from_xmls(slide_xmls)
|
||||
)
|
||||
fonts: dict[str, str] = {name: url for name, url in available_fonts}
|
||||
fonts.update({font.display_name: font.url for font in stored_fonts})
|
||||
|
||||
return FontsUploadAndSlidesPreviewResponse(
|
||||
slide_image_urls=slide_image_urls,
|
||||
pptx_url=pptx_url,
|
||||
modified_pptx_url=pptx_url,
|
||||
fonts=fonts,
|
||||
)
|
||||
|
|
@ -398,3 +398,38 @@ class TestImageGenerationEndpoint:
|
|||
|
||||
asyncio.run(run_test())
|
||||
|
||||
def test_search_stock_images_defaults_to_selected_pixabay(self, client, mock_images_directory):
|
||||
"""
|
||||
Test stock image search defaults to IMAGE_PROVIDER when provider query param is omitted
|
||||
- Sets IMAGE_PROVIDER to pixabay
|
||||
- Ensures /images/search uses Pixabay instead of returning provider validation error
|
||||
"""
|
||||
with patch.dict(os.environ, {"IMAGE_PROVIDER": "pixabay"}):
|
||||
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.get_image_from_pixabay = AsyncMock(
|
||||
return_value=["https://example.com/pixabay_image.jpg"]
|
||||
)
|
||||
mock_service_instance.get_image_from_pexels = AsyncMock(
|
||||
return_value=["https://example.com/pexels_image.jpg"]
|
||||
)
|
||||
mock_service_class.return_value = mock_service_instance
|
||||
|
||||
response = client.get("/images/search?query=business&limit=1")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == ["https://example.com/pixabay_image.jpg"]
|
||||
mock_service_instance.get_image_from_pixabay.assert_awaited_once()
|
||||
mock_service_instance.get_image_from_pexels.assert_not_called()
|
||||
|
||||
def test_search_stock_images_invalid_provider_returns_400(self, client):
|
||||
"""
|
||||
Test stock image search validates invalid provider values
|
||||
- Ensures unsupported providers return HTTP 400 with clear guidance
|
||||
"""
|
||||
response = client.get("/images/search?query=business&provider=invalid-provider")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "provider must be either 'pexels' or 'pixabay'"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,189 +1,117 @@
|
|||
from unittest.mock import patch, AsyncMock, MagicMock
|
||||
import asyncio
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from fastapi import FastAPI
|
||||
from models.presentation_layout import PresentationLayoutModel
|
||||
from models.presentation_structure_model import PresentationStructureModel
|
||||
from api.v1.ppt.endpoints.presentation import PRESENTATION_ROUTER
|
||||
from fastapi import HTTPException
|
||||
from pydantic import ValidationError
|
||||
|
||||
class MockAiohttpResponse:
|
||||
def __init__(self, status=200, json_data=None):
|
||||
self.status = status
|
||||
self._json_data = json_data or {"path": "/tmp/exports/test.pdf"}
|
||||
from api.v1.ppt.endpoints.presentation import generate_presentation_sync
|
||||
from models.generate_presentation_request import GeneratePresentationRequest
|
||||
from models.presentation_and_path import PresentationPathAndEditPath
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
pass
|
||||
class FakeAsyncSession:
|
||||
async def get(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
async def json(self):
|
||||
return self._json_data
|
||||
def add(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
async def text(self):
|
||||
return str(self._json_data)
|
||||
def add_all(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
class MockAiohttpSession:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
async def commit(self):
|
||||
return None
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
pass
|
||||
|
||||
def post(self, *args, **kwargs):
|
||||
return MockAiohttpResponse()
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
pptx_model_data = {
|
||||
"slides": [],
|
||||
"title": "Test",
|
||||
"notes": [],
|
||||
"layout": {},
|
||||
"structure": {},
|
||||
}
|
||||
return MockAiohttpResponse(json_data=pptx_model_data)
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
app = FastAPI()
|
||||
app.include_router(PRESENTATION_ROUTER, prefix="/api/v1/ppt")
|
||||
return app
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
return TestClient(app)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_get_layout():
|
||||
async def _mock_get_layout_by_name(layout_name: str):
|
||||
mock_slide = MagicMock()
|
||||
mock_slide.name = "Mock Slide"
|
||||
mock_slide.json_schema = {"title": "Mock Slide Title"}
|
||||
mock_slide.description = "Mock slide description"
|
||||
mock_layout = MagicMock(spec=PresentationLayoutModel)
|
||||
mock_layout.name = layout_name
|
||||
mock_layout.ordered = True
|
||||
mock_layout.slides = [mock_slide]
|
||||
mock_layout.model_dump = lambda: {}
|
||||
mock_layout.to_presentation_structure = lambda: PresentationStructureModel(
|
||||
slides=[index for index in range(len(mock_layout.slides))]
|
||||
)
|
||||
def to_string():
|
||||
message = f"## Presentation Layout\n\n"
|
||||
for index, slide in enumerate(mock_layout.slides):
|
||||
message += f"### Slide Layout: {index}: \n"
|
||||
message += f"- Name: {slide.name or slide.json_schema.get('title')} \n"
|
||||
message += f"- Description: {slide.description} \n\n"
|
||||
return message
|
||||
mock_layout.to_string = to_string
|
||||
return mock_layout
|
||||
return _mock_get_layout_by_name
|
||||
|
||||
async def mock_generate_ppt_outline(*args, **kwargs):
|
||||
yield '{"title": "Test", "slides": [{"title": "Slide 1", "body": "Body 1"}], "notes": []}'
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_presentation_api(monkeypatch, mock_get_layout):
|
||||
# Patch all dependencies used in the API
|
||||
patches = [
|
||||
patch('api.v1.ppt.endpoints.presentation.get_layout_by_name', new=AsyncMock(side_effect=mock_get_layout)),
|
||||
patch('api.v1.ppt.endpoints.presentation.TEMP_FILE_SERVICE.create_temp_dir', return_value='/tmp/mockdir'),
|
||||
patch('api.v1.ppt.endpoints.presentation.DocumentsLoader'),
|
||||
patch('api.v1.ppt.endpoints.presentation.generate_document_summary', new_callable=AsyncMock, return_value="mock_summary"),
|
||||
patch('api.v1.ppt.endpoints.presentation.generate_ppt_outline', side_effect=mock_generate_ppt_outline),
|
||||
patch('api.v1.ppt.endpoints.presentation.get_sql_session'),
|
||||
patch('api.v1.ppt.endpoints.presentation.get_slide_content_from_type_and_outline', new_callable=AsyncMock, return_value={"mock": "slide_content"}),
|
||||
patch('api.v1.ppt.endpoints.presentation.process_slide_and_fetch_assets', new_callable=AsyncMock),
|
||||
patch('api.v1.ppt.endpoints.presentation.get_exports_directory', return_value='/tmp/exports'),
|
||||
patch('api.v1.ppt.endpoints.presentation.PptxPresentationCreator'),
|
||||
patch('api.v1.ppt.endpoints.presentation.aiohttp.ClientSession', return_value=MockAiohttpSession()),
|
||||
]
|
||||
mocks = [p.start() for p in patches]
|
||||
|
||||
# Setup DocumentsLoader mock
|
||||
docs_loader = mocks[2]
|
||||
docs_loader.return_value.load_documents = AsyncMock()
|
||||
docs_loader.return_value.documents = []
|
||||
|
||||
# Setup PptxPresentationCreator mock for pptx test
|
||||
pptx_creator = mocks[9]
|
||||
pptx_creator.return_value.create_ppt = AsyncMock()
|
||||
pptx_creator.return_value.save = MagicMock()
|
||||
|
||||
yield
|
||||
|
||||
for p in patches:
|
||||
p.stop()
|
||||
|
||||
class TestPresentationGenerationAPI:
|
||||
def test_generate_presentation_export_as_pdf(self, client):
|
||||
response = client.post(
|
||||
"/api/v1/ppt/presentation/generate",
|
||||
json={
|
||||
"content": "Create a presentation about artificial intelligence and machine learning",
|
||||
"n_slides": 5,
|
||||
"language": "English",
|
||||
"export_as": "pdf",
|
||||
"layout": "general"
|
||||
}
|
||||
def test_generate_presentation_export_as_pdf(self):
|
||||
request = GeneratePresentationRequest(
|
||||
content="Create a presentation about artificial intelligence and machine learning",
|
||||
n_slides=5,
|
||||
language="English",
|
||||
export_as="pdf",
|
||||
template="general",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "presentation_id" in response.json()
|
||||
assert "pdf" in response.json()["path"]
|
||||
|
||||
def test_generate_presentation_export_as_pptx(self, client):
|
||||
response = client.post(
|
||||
"/api/v1/ppt/presentation/generate",
|
||||
json={
|
||||
"content": "Create a presentation about artificial intelligence and machine learning",
|
||||
"n_slides": 5,
|
||||
"language": "English",
|
||||
"export_as": "pptx",
|
||||
"layout": "general"
|
||||
}
|
||||
response_payload = PresentationPathAndEditPath(
|
||||
presentation_id=uuid.uuid4(),
|
||||
path="/tmp/exports/test.pdf",
|
||||
edit_path="/presentation?id=test",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "presentation_id" in response.json()
|
||||
assert "pptx" in response.json()["path"]
|
||||
|
||||
def test_generate_presentation_with_no_content(self, client):
|
||||
response = client.post(
|
||||
"/api/v1/ppt/presentation/generate",
|
||||
json={
|
||||
"n_slides": 5,
|
||||
"language": "English",
|
||||
"export_as": "pdf",
|
||||
"layout": "general"
|
||||
}
|
||||
with patch(
|
||||
"api.v1.ppt.endpoints.presentation.generate_presentation_handler",
|
||||
new=AsyncMock(return_value=response_payload),
|
||||
) as mock_handler:
|
||||
response = asyncio.run(
|
||||
generate_presentation_sync(request, sql_session=FakeAsyncSession())
|
||||
)
|
||||
|
||||
assert response == response_payload
|
||||
mock_handler.assert_awaited_once()
|
||||
|
||||
def test_generate_presentation_export_as_pptx(self):
|
||||
request = GeneratePresentationRequest(
|
||||
content="Create a presentation about artificial intelligence and machine learning",
|
||||
n_slides=5,
|
||||
language="English",
|
||||
export_as="pptx",
|
||||
template="general",
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_generate_presentation_with_n_slides_less_than_one(self, client):
|
||||
response = client.post(
|
||||
"/api/v1/ppt/presentation/generate",
|
||||
json={
|
||||
"content": "Create a presentation about artificial intelligence and machine learning",
|
||||
"n_slides": 0,
|
||||
"language": "English",
|
||||
"export_as": "pdf",
|
||||
"layout": "general"
|
||||
}
|
||||
response_payload = PresentationPathAndEditPath(
|
||||
presentation_id=uuid.uuid4(),
|
||||
path="/tmp/exports/test.pptx",
|
||||
edit_path="/presentation?id=test",
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_generate_presentation_with_invalid_export_type(self, client):
|
||||
response = client.post(
|
||||
"/api/v1/ppt/presentation/generate",
|
||||
json={
|
||||
"content": "Create a presentation about artificial intelligence and machine learning",
|
||||
"n_slides": 5,
|
||||
"language": "English",
|
||||
"export_as": "invalid_type",
|
||||
"layout": "general"
|
||||
}
|
||||
with patch(
|
||||
"api.v1.ppt.endpoints.presentation.generate_presentation_handler",
|
||||
new=AsyncMock(return_value=response_payload),
|
||||
) as mock_handler:
|
||||
response = asyncio.run(
|
||||
generate_presentation_sync(request, sql_session=FakeAsyncSession())
|
||||
)
|
||||
|
||||
assert response == response_payload
|
||||
mock_handler.assert_awaited_once()
|
||||
|
||||
def test_generate_presentation_with_no_content(self):
|
||||
with pytest.raises(ValidationError):
|
||||
GeneratePresentationRequest.model_validate(
|
||||
{
|
||||
"n_slides": 5,
|
||||
"language": "English",
|
||||
"export_as": "pdf",
|
||||
"template": "general",
|
||||
}
|
||||
)
|
||||
|
||||
def test_generate_presentation_with_n_slides_less_than_one(self):
|
||||
request = GeneratePresentationRequest(
|
||||
content="Create a presentation about artificial intelligence and machine learning",
|
||||
n_slides=0,
|
||||
language="English",
|
||||
export_as="pdf",
|
||||
template="general",
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
asyncio.run(
|
||||
generate_presentation_sync(request, sql_session=FakeAsyncSession())
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
assert exc.value.detail == "Number of slides must be greater than 0"
|
||||
|
||||
def test_generate_presentation_with_invalid_export_type(self):
|
||||
with pytest.raises(ValidationError):
|
||||
GeneratePresentationRequest.model_validate(
|
||||
{
|
||||
"content": "Create a presentation about artificial intelligence and machine learning",
|
||||
"n_slides": 5,
|
||||
"language": "English",
|
||||
"export_as": "invalid_type",
|
||||
"template": "general",
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,12 +5,13 @@ from urllib.parse import urlparse, unquote
|
|||
from utils.get_env import get_app_data_directory_env
|
||||
|
||||
|
||||
def resolve_image_path_to_filesystem(path_or_url: str) -> Optional[str]:
|
||||
def resolve_app_path_to_filesystem(path_or_url: str) -> Optional[str]:
|
||||
"""
|
||||
Resolve an image path or URL to an actual filesystem path.
|
||||
Resolve an app-served path or URL to an actual filesystem path.
|
||||
|
||||
Handles:
|
||||
- Path strings: /app_data/images/..., /static/..., absolute paths, relative
|
||||
- file:// URLs returned by export runtimes
|
||||
- HTTP URLs whose path component is an absolute filesystem path (Mac/Electron):
|
||||
When img src is /Users/.../images/xxx.png, browser resolves to
|
||||
http://origin/Users/.../images/xxx.png. Next.js returns 404 for these.
|
||||
|
|
@ -21,10 +22,12 @@ def resolve_image_path_to_filesystem(path_or_url: str) -> Optional[str]:
|
|||
return None
|
||||
# Extract path from HTTP URL if needed
|
||||
path = path_or_url
|
||||
if path_or_url.startswith("http"):
|
||||
if path_or_url.startswith("http") or path_or_url.startswith("file:"):
|
||||
try:
|
||||
parsed = urlparse(path_or_url)
|
||||
path = unquote(parsed.path)
|
||||
if parsed.scheme == "file" and os.name == "nt" and path.startswith("/"):
|
||||
path = path[1:]
|
||||
except Exception:
|
||||
return None
|
||||
# Handle /app_data/images/
|
||||
|
|
@ -63,6 +66,10 @@ def resolve_image_path_to_filesystem(path_or_url: str) -> Optional[str]:
|
|||
return actual if os.path.isfile(actual) else None
|
||||
|
||||
|
||||
def resolve_image_path_to_filesystem(path_or_url: str) -> Optional[str]:
|
||||
return resolve_app_path_to_filesystem(path_or_url)
|
||||
|
||||
|
||||
def get_images_directory():
|
||||
images_directory = os.path.join(get_app_data_directory_env(), "images")
|
||||
os.makedirs(images_directory, exist_ok=True)
|
||||
|
|
|
|||
|
|
@ -4,11 +4,31 @@ from urllib.parse import urlsplit, urlunsplit, parse_qsl
|
|||
import ssl
|
||||
|
||||
|
||||
def _ensure_sqlite_parent_dir(database_url: str) -> None:
|
||||
if not database_url.startswith("sqlite://"):
|
||||
return
|
||||
|
||||
split_result = urlsplit(database_url)
|
||||
db_path = split_result.path
|
||||
if not db_path:
|
||||
return
|
||||
|
||||
# sqlite URLs on Windows can start with /C:/..., normalize that for os.path.
|
||||
if os.name == "nt" and len(db_path) >= 3 and db_path[0] == "/" and db_path[2] == ":":
|
||||
db_path = db_path[1:]
|
||||
|
||||
parent = os.path.dirname(db_path)
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
|
||||
|
||||
def get_database_url_and_connect_args() -> tuple[str, dict]:
|
||||
database_url = get_database_url_env() or "sqlite:///" + os.path.join(
|
||||
get_app_data_directory_env() or "/tmp/presenton", "fastapi.db"
|
||||
)
|
||||
|
||||
_ensure_sqlite_parent_dir(database_url)
|
||||
|
||||
if database_url.startswith("sqlite://"):
|
||||
database_url = database_url.replace("sqlite://", "sqlite+aiosqlite://", 1)
|
||||
elif database_url.startswith("postgresql://"):
|
||||
|
|
|
|||
|
|
@ -136,9 +136,37 @@ def get_codex_account_id_env():
|
|||
return os.getenv("CODEX_ACCOUNT_ID")
|
||||
|
||||
|
||||
def get_codex_username_env():
|
||||
return os.getenv("CODEX_USERNAME")
|
||||
|
||||
|
||||
def get_codex_email_env():
|
||||
return os.getenv("CODEX_EMAIL")
|
||||
|
||||
|
||||
def get_codex_is_pro_env():
|
||||
return os.getenv("CODEX_IS_PRO")
|
||||
|
||||
|
||||
def get_codex_model_env():
|
||||
return os.getenv("CODEX_MODEL")
|
||||
|
||||
|
||||
def get_migrate_database_on_startup_env():
|
||||
return os.getenv("MIGRATE_DATABASE_ON_STARTUP")
|
||||
|
||||
|
||||
def get_next_public_fast_api_env():
|
||||
return os.getenv("FASTAPI_PUBLIC_URL")
|
||||
|
||||
|
||||
def get_sentry_dsn_env():
|
||||
return os.getenv("SENTRY_DSN")
|
||||
|
||||
|
||||
def get_sentry_traces_sample_rate_env():
|
||||
return os.getenv("SENTRY_TRACES_SAMPLE_RATE")
|
||||
|
||||
|
||||
def get_sentry_send_default_pii_env():
|
||||
return os.getenv("SENTRY_SEND_DEFAULT_PII")
|
||||
|
|
|
|||
|
|
@ -9,6 +9,17 @@ from utils.llm_provider import get_model
|
|||
from utils.schema_utils import add_field_in_schema, remove_fields_from_schema
|
||||
|
||||
|
||||
def _resolve_prompt_language(language: Optional[str]) -> str:
|
||||
if language is None:
|
||||
return "auto-detect"
|
||||
s = str(language).strip()
|
||||
if not s:
|
||||
return "auto-detect"
|
||||
if s.lower() in {"auto", "auto-detect"}:
|
||||
return "auto-detect"
|
||||
return s
|
||||
|
||||
|
||||
def get_system_prompt(
|
||||
tone: Optional[str] = None,
|
||||
verbosity: Optional[str] = None,
|
||||
|
|
@ -40,6 +51,7 @@ def get_system_prompt(
|
|||
|
||||
|
||||
def get_user_prompt(prompt: str, slide_data: dict, language: str):
|
||||
display_language = _resolve_prompt_language(language)
|
||||
return f"""
|
||||
## Icon Query And Image Prompt Language
|
||||
English
|
||||
|
|
@ -48,7 +60,7 @@ def get_user_prompt(prompt: str, slide_data: dict, language: str):
|
|||
{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
|
||||
|
||||
## Slide Content Language
|
||||
{language}
|
||||
{display_language}
|
||||
|
||||
## Prompt
|
||||
{prompt}
|
||||
|
|
@ -61,7 +73,7 @@ def get_user_prompt(prompt: str, slide_data: dict, language: str):
|
|||
def get_messages(
|
||||
prompt: str,
|
||||
slide_data: dict,
|
||||
language: str,
|
||||
language: Optional[str],
|
||||
tone: Optional[str] = None,
|
||||
verbosity: Optional[str] = None,
|
||||
instructions: Optional[str] = None,
|
||||
|
|
@ -79,7 +91,7 @@ def get_messages(
|
|||
async def get_edited_slide_content(
|
||||
prompt: str,
|
||||
slide: SlideModel,
|
||||
language: str,
|
||||
language: Optional[str],
|
||||
slide_layout: SlideLayoutModel,
|
||||
tone: Optional[str] = None,
|
||||
verbosity: Optional[str] = None,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from datetime import datetime
|
|||
from typing import Optional
|
||||
|
||||
from models.llm_message import LLMSystemMessage, LLMUserMessage
|
||||
from models.presentation_outline_model import PresentationOutlineModel
|
||||
from models.llm_tools import SearchWebTool
|
||||
from services.llm_client import LLMClient
|
||||
from utils.get_dynamic_models import get_presentation_outline_model_with_n_slides
|
||||
|
|
@ -14,77 +15,144 @@ def get_system_prompt(
|
|||
verbosity: Optional[str] = None,
|
||||
instructions: Optional[str] = None,
|
||||
include_title_slide: bool = True,
|
||||
include_table_of_contents: bool = False,
|
||||
):
|
||||
return f"""
|
||||
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.
|
||||
verbosity_instruction = (
|
||||
"Slide content should be abound 20 words but detailed enough to generate a good slide."
|
||||
if verbosity == "concise"
|
||||
else (
|
||||
"Slide content should be abound 60 words but detailed enough to generate a good slide."
|
||||
if verbosity == "text-heavy"
|
||||
else "Slide content should be abound 40 words but detailed enough to generate a good slide."
|
||||
)
|
||||
)
|
||||
|
||||
Try to use available tools for better results.
|
||||
title_slide_instruction = (
|
||||
"Include presenter name in first slide."
|
||||
if include_title_slide
|
||||
else "Do not include presenter name in any slides."
|
||||
)
|
||||
|
||||
{"# User Instruction:" if instructions else ""}
|
||||
{instructions or ""}
|
||||
toc_instruction = (
|
||||
"Include a table of contents slide in the outline sequence."
|
||||
if include_table_of_contents
|
||||
else ""
|
||||
)
|
||||
toc_block = f"{toc_instruction}\n" if toc_instruction else ""
|
||||
|
||||
{"# Tone:" if tone else ""}
|
||||
{tone or ""}
|
||||
slide_outline_structure = (
|
||||
"Each slide content:\n"
|
||||
" - Must have a ## title.\n"
|
||||
# " - Must have content either in multiple bullet points or table or both.\n"
|
||||
" - Must be in Markdown format.\n"
|
||||
" - Don't use **bold** and __italic__ text."
|
||||
" - First slide title must be the same as the presentation title."
|
||||
)
|
||||
|
||||
{"# Verbosity:" if verbosity else ""}
|
||||
{verbosity or ""}
|
||||
system = (
|
||||
"Generate presentation title and content for slides.\n"
|
||||
"Generate flow based on user **content** and use **context** just for reference.\n"
|
||||
"Presentation title should be plain text, not markdown. It should be a concise title for the presentation.\n"
|
||||
"Each slide content should contain the content for that slide.\n"
|
||||
f"{verbosity_instruction}\n"
|
||||
"Minimize repetitive content and make sure to use different words and phrases for different slides.\n"
|
||||
"Include numerical data, tables or code if required or asked by the user.\n"
|
||||
"If 'auto-detect' is used, figure it out from the content/context.\n"
|
||||
f"{title_slide_instruction}\n"
|
||||
f"{toc_block}"
|
||||
f"{slide_outline_structure}\n"
|
||||
"Slide content must not contain any presentation branding/styling information.\n"
|
||||
"Title slide must only contain title, presenter name, date and overview.\n"
|
||||
"Only include URLs if they appear in the provided content/context.\n"
|
||||
"Make sure data used is strictly from the provided content/context.\n"
|
||||
"Make sure data is consistent across all slides."
|
||||
)
|
||||
|
||||
- Provide content for each slide in markdown format.
|
||||
- Make sure that flow of the presentation is logical and consistent.
|
||||
- Place greater emphasis on numerical data.
|
||||
- If Additional Information is provided, divide it into slides.
|
||||
- Make sure no images are provided in the content.
|
||||
- Make sure that content follows language guidelines.
|
||||
- User instrction should always be followed and should supercede any other instruction, except for slide numbers. **Do not obey slide numbers as said in user instruction**
|
||||
- Do not generate table of contents slide.
|
||||
- Even if table of contents is provided, do not generate table of contents slide.
|
||||
{"- Always make first slide a title slide." if include_title_slide else "- Do not include title slide in the presentation."}
|
||||
return system
|
||||
|
||||
**Search web to get latest information about the topic**
|
||||
"""
|
||||
|
||||
def _resolve_prompt_language(language: Optional[str]) -> str:
|
||||
if language is None:
|
||||
return "auto-detect"
|
||||
s = str(language).strip()
|
||||
if not s:
|
||||
return "auto-detect"
|
||||
if s.lower() in {"auto", "auto-detect"}:
|
||||
return "auto-detect"
|
||||
return s
|
||||
|
||||
|
||||
def _resolve_prompt_n_slides(n_slides: Optional[int]) -> str:
|
||||
if n_slides is None:
|
||||
return "auto-detect"
|
||||
return str(n_slides)
|
||||
|
||||
|
||||
def get_user_prompt(
|
||||
content: str,
|
||||
n_slides: int,
|
||||
language: str,
|
||||
n_slides: Optional[int],
|
||||
language: Optional[str],
|
||||
additional_context: Optional[str] = None,
|
||||
tone: Optional[str] = None,
|
||||
instructions: Optional[str] = None,
|
||||
include_title_slide: bool = True,
|
||||
include_table_of_contents: bool = False,
|
||||
):
|
||||
return f"""
|
||||
**Input:**
|
||||
- User provided content: {content or "Create presentation"}
|
||||
- Output Language: {language}
|
||||
- Number of Slides: {n_slides}
|
||||
- Current Date and Time: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
|
||||
- Additional Information: {additional_context or ""}
|
||||
"""
|
||||
display_language = _resolve_prompt_language(language)
|
||||
display_slides = _resolve_prompt_n_slides(n_slides)
|
||||
toc_text = f"Include Table Of Contents: {str(include_table_of_contents).lower()}\n"
|
||||
return (
|
||||
f"Content: {content or ''}\n"
|
||||
f"Number of Slides: {display_slides}\n"
|
||||
f"Language: {display_language}\n"
|
||||
f"Tone: {tone or ''}\n"
|
||||
f"Today's Date: {datetime.now().strftime('%Y-%m-%d')}\n"
|
||||
f"Include Title Slide: {include_title_slide}\n"
|
||||
f"{toc_text if include_table_of_contents else ''}"
|
||||
f"Instructions: {instructions or ''}\n"
|
||||
f"Context: {additional_context or ''}"
|
||||
)
|
||||
|
||||
|
||||
def get_messages(
|
||||
content: str,
|
||||
n_slides: int,
|
||||
language: str,
|
||||
n_slides: Optional[int],
|
||||
language: Optional[str],
|
||||
additional_context: Optional[str] = None,
|
||||
tone: Optional[str] = None,
|
||||
verbosity: Optional[str] = None,
|
||||
instructions: Optional[str] = None,
|
||||
include_title_slide: bool = True,
|
||||
include_table_of_contents: bool = False,
|
||||
):
|
||||
return [
|
||||
LLMSystemMessage(
|
||||
content=get_system_prompt(
|
||||
tone, verbosity, instructions, include_title_slide
|
||||
tone,
|
||||
verbosity,
|
||||
instructions,
|
||||
include_title_slide,
|
||||
include_table_of_contents,
|
||||
),
|
||||
),
|
||||
LLMUserMessage(
|
||||
content=get_user_prompt(content, n_slides, language, additional_context),
|
||||
content=get_user_prompt(
|
||||
content,
|
||||
n_slides,
|
||||
language,
|
||||
additional_context,
|
||||
tone,
|
||||
instructions,
|
||||
include_title_slide,
|
||||
include_table_of_contents,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def generate_ppt_outline(
|
||||
content: str,
|
||||
n_slides: int,
|
||||
n_slides: Optional[int],
|
||||
language: Optional[str] = None,
|
||||
additional_context: Optional[str] = None,
|
||||
tone: Optional[str] = None,
|
||||
|
|
@ -92,9 +160,14 @@ async def generate_ppt_outline(
|
|||
instructions: Optional[str] = None,
|
||||
include_title_slide: bool = True,
|
||||
web_search: bool = False,
|
||||
include_table_of_contents: bool = False,
|
||||
):
|
||||
model = get_model()
|
||||
response_model = get_presentation_outline_model_with_n_slides(n_slides)
|
||||
response_model = (
|
||||
get_presentation_outline_model_with_n_slides(n_slides)
|
||||
if n_slides is not None
|
||||
else PresentationOutlineModel
|
||||
)
|
||||
|
||||
client = LLMClient()
|
||||
|
||||
|
|
@ -110,6 +183,7 @@ async def generate_ppt_outline(
|
|||
verbosity,
|
||||
instructions,
|
||||
include_title_slide,
|
||||
include_table_of_contents,
|
||||
),
|
||||
response_model.model_json_schema(),
|
||||
strict=True,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from typing import Optional
|
||||
from typing import Optional, Dict
|
||||
|
||||
from models.llm_message import LLMSystemMessage, LLMUserMessage
|
||||
from models.presentation_layout import PresentationLayoutModel
|
||||
from models.presentation_outline_model import PresentationOutlineModel
|
||||
|
|
@ -9,58 +10,106 @@ from utils.get_dynamic_models import get_presentation_structure_model_with_n_sli
|
|||
from models.presentation_structure_model import PresentationStructureModel
|
||||
|
||||
|
||||
STRUCTURE_FROM_SLIDES_MARKDOWN_SYSTEM_PROMPT = """
|
||||
You will be given available slide layouts and content for each slide.
|
||||
You need to select a layout for each slide based on the mentioned guidelines.
|
||||
|
||||
# Steps
|
||||
1. Analyze all available slide layouts.
|
||||
2. Analyze content for each slide.
|
||||
3. Select a layout for each slide one by one by following the selection rules.
|
||||
|
||||
# Analyzing Slide Layouts
|
||||
- Identify what each layout contains based on provided schema markdown.
|
||||
|
||||
# Analyzing Content
|
||||
- Identify how the content is structured.
|
||||
- Identify if the content contains tables.
|
||||
|
||||
# Selection Rules
|
||||
- If content contains table, then select either table layout or graph layout.
|
||||
- Don't select layout with image unless content contains image.
|
||||
- Don't select table layout if content does not contain table.
|
||||
- You are allowed to select same layout for multiple slides.
|
||||
|
||||
# Table Layout Selection Rules
|
||||
- Must select table layout if the content contains table with text data.
|
||||
- Must only select a layout with table if the table only contains text data.
|
||||
|
||||
# Graph Layout Selection Rules
|
||||
- Must only select a layout with chart if the content contains table with numeric data.
|
||||
- Identify how many columns are present in the table.
|
||||
- Must select a layout that supports n-1 charts for n columns.
|
||||
- Must prioritize layouts that support multiple charts.
|
||||
- Don't select metrics layout for content containing table with numeric data.
|
||||
- For example, if content contains table with 3 columns, then select a layout that supports 2 charts.
|
||||
|
||||
{user_instructions}
|
||||
|
||||
# Output Rules:
|
||||
- One layout index for each slide.
|
||||
- Example: [0, 1, 2, 3, 4]
|
||||
|
||||
{presentation_layout}
|
||||
"""
|
||||
|
||||
|
||||
GET_MESSAGES_SYSTEM_PROMPT = """
|
||||
You're a professional presentation designer with creative freedom to design engaging presentations.
|
||||
|
||||
# DESIGN PHILOSOPHY
|
||||
- Create visually compelling and varied presentations
|
||||
- Match layout to content purpose and audience needs
|
||||
|
||||
# Layout Selection Guidelines
|
||||
1. **Content-driven choices**: Let the slide's purpose guide layout selection
|
||||
- Opening/closing → Title layouts
|
||||
- Processes/workflows → Visual process layouts
|
||||
- Comparisons/contrasts → Side-by-side layouts
|
||||
- Data/metrics → Chart/graph layouts
|
||||
- Concepts/ideas → Image + text layouts
|
||||
- Key insights → Emphasis layouts
|
||||
|
||||
2. **Visual variety**: Aim for diverse slide layouts across the presentation.
|
||||
- Don't use same layout for multiple slides unless necessary.
|
||||
- Mix text-heavy and visual-heavy slides naturally
|
||||
- Use your judgment on when repetition serves the content
|
||||
- Balance information density across slides
|
||||
- Adjacent slide layouts should be different unless instructed/necessary otherwise.
|
||||
|
||||
3. **Audience experience**: Consider how slides work together
|
||||
- Create natural transitions between topics
|
||||
|
||||
4. **Table of contents**:
|
||||
- Must only use table of contents layout if slide content contains table of contents.
|
||||
|
||||
{user_instruction_header}
|
||||
|
||||
User instruction should be taken into account while creating the presentation structure, except for number of slides.
|
||||
|
||||
Select layout index for each of the {n_slides} slides based on what will best serve the presentation's goals.
|
||||
|
||||
"""
|
||||
|
||||
|
||||
def get_messages(
|
||||
presentation_layout: PresentationLayoutModel,
|
||||
n_slides: int,
|
||||
data: str,
|
||||
instructions: Optional[str] = None,
|
||||
):
|
||||
system_prompt = GET_MESSAGES_SYSTEM_PROMPT.format(
|
||||
user_instruction_header="# User Instruction:" if instructions else "",
|
||||
n_slides=n_slides,
|
||||
)
|
||||
|
||||
return [
|
||||
LLMSystemMessage(
|
||||
content=f"""
|
||||
You're a professional presentation designer with creative freedom to design engaging presentations.
|
||||
|
||||
{presentation_layout.to_string()}
|
||||
|
||||
# DESIGN PHILOSOPHY
|
||||
- Create visually compelling and varied presentations
|
||||
- Match layout to content purpose and audience needs
|
||||
- Prioritize engagement over rigid formatting rules
|
||||
|
||||
# Layout Selection Guidelines
|
||||
1. **Content-driven choices**: Let the slide's purpose guide layout selection
|
||||
- Opening/closing → Title layouts
|
||||
- Processes/workflows → Visual process layouts
|
||||
- Comparisons/contrasts → Side-by-side layouts
|
||||
- Data/metrics → Chart/graph layouts
|
||||
- Concepts/ideas → Image + text layouts
|
||||
- Key insights → Emphasis layouts
|
||||
|
||||
2. **Visual variety**: Aim for diverse, engaging presentation flow
|
||||
- Mix text-heavy and visual-heavy slides naturally
|
||||
- Use your judgment on when repetition serves the content
|
||||
- Balance information density across slides
|
||||
|
||||
3. **Audience experience**: Consider how slides work together
|
||||
- Create natural transitions between topics
|
||||
- Use layouts that enhance comprehension
|
||||
- Design for maximum impact and retention
|
||||
|
||||
**Trust your design instincts. Focus on creating the most effective presentation for the content and audience.**
|
||||
|
||||
{"# User Instruction:" if instructions else ""}
|
||||
{instructions or ""}
|
||||
|
||||
User intruction should be taken into account while creating the presentation structure, except for number of slides.
|
||||
|
||||
Select layout index for each of the {n_slides} slides based on what will best serve the presentation's goals.
|
||||
""",
|
||||
),
|
||||
LLMUserMessage(
|
||||
content=f"""
|
||||
{data}
|
||||
""",
|
||||
),
|
||||
LLMSystemMessage(content=system_prompt),
|
||||
LLMUserMessage(content=(
|
||||
f"{presentation_layout.to_string()}\n\n"
|
||||
"--------------------------------------\n\n"
|
||||
f"{data}"
|
||||
)),
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -70,28 +119,18 @@ def get_messages_for_slides_markdown(
|
|||
data: str,
|
||||
instructions: Optional[str] = None,
|
||||
):
|
||||
system_prompt = STRUCTURE_FROM_SLIDES_MARKDOWN_SYSTEM_PROMPT.format(
|
||||
user_instructions=instructions or "",
|
||||
presentation_layout=presentation_layout.to_string(with_schema=True),
|
||||
)
|
||||
|
||||
return [
|
||||
LLMSystemMessage(
|
||||
content=f"""
|
||||
You're a professional presentation designer with creative freedom to design engaging presentations.
|
||||
|
||||
{"# User Instruction:" if instructions else ""}
|
||||
{instructions or ""}
|
||||
|
||||
{presentation_layout.to_string()}
|
||||
|
||||
Select layout that best matches the content of the slides.
|
||||
|
||||
User intruction should be taken into account while creating the presentation structure, except for number of slides.
|
||||
|
||||
Select layout index for each of the {n_slides} slides based on what will best serve the presentation's goals.
|
||||
""",
|
||||
content=system_prompt
|
||||
),
|
||||
LLMUserMessage(
|
||||
content=f"""
|
||||
{data}
|
||||
""",
|
||||
),
|
||||
content=data
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from datetime import datetime
|
||||
import json
|
||||
from typing import Optional
|
||||
from models.llm_message import LLMSystemMessage, LLMUserMessage
|
||||
from models.presentation_layout import SlideLayoutModel
|
||||
|
|
@ -9,88 +10,136 @@ from utils.llm_provider import get_model
|
|||
from utils.schema_utils import add_field_in_schema, remove_fields_from_schema
|
||||
|
||||
|
||||
SLIDE_CONTENT_SYSTEM_PROMPT = """
|
||||
You will be given slide content and response schema.
|
||||
You need to generate structured content json based on the schema.
|
||||
|
||||
# Steps
|
||||
1. Analyze the content.
|
||||
2. Analyze the response schema.
|
||||
3. Generate structured content json based on the schema.
|
||||
4. Generate speaker note if required.
|
||||
5. Provide structured content json as output.
|
||||
|
||||
# General Rules
|
||||
- Make sure to follow language guidelines.
|
||||
- Speaker note should be normal text, not markdown.
|
||||
- Never ever go over the max character limit.
|
||||
- Do not add emoji in the content.
|
||||
- Don't provide $schema field in content json.
|
||||
{markdown_emphasis_rules}
|
||||
|
||||
{user_instructions}
|
||||
|
||||
{tone_instructions}
|
||||
|
||||
{verbosity_instructions}
|
||||
|
||||
{output_fields_instructions}
|
||||
"""
|
||||
|
||||
|
||||
SLIDE_CONTENT_USER_PROMPT = """
|
||||
# Current Date and Time:
|
||||
{current_date_time}
|
||||
|
||||
# Icon Query And Image Prompt Language:
|
||||
English
|
||||
|
||||
# Slide Language:
|
||||
{language}
|
||||
|
||||
# SLIDE CONTENT: START
|
||||
{content}
|
||||
# SLIDE CONTENT: END
|
||||
"""
|
||||
|
||||
|
||||
def _resolve_prompt_language(language: Optional[str]) -> str:
|
||||
if language is None:
|
||||
return "auto-detect"
|
||||
s = str(language).strip()
|
||||
if not s:
|
||||
return "auto-detect"
|
||||
if s.lower() in {"auto", "auto-detect"}:
|
||||
return "auto-detect"
|
||||
return s
|
||||
|
||||
|
||||
def _get_schema_markdown(response_schema: Optional[dict]) -> str:
|
||||
if not response_schema:
|
||||
return "- Follow the provided response schema strictly."
|
||||
try:
|
||||
schema_text = json.dumps(response_schema, ensure_ascii=False)
|
||||
except Exception:
|
||||
return "- Follow the provided response schema strictly."
|
||||
return f"- Follow this response schema exactly: {schema_text}"
|
||||
|
||||
|
||||
def get_system_prompt(
|
||||
tone: Optional[str] = None,
|
||||
verbosity: Optional[str] = None,
|
||||
instructions: Optional[str] = None,
|
||||
response_schema: Optional[dict] = None,
|
||||
):
|
||||
return f"""
|
||||
Generate structured slide based on provided outline, follow mentioned steps and notes and provide structured output.
|
||||
markdown_emphasis_rules = (
|
||||
"- Strictly use markdown to emphasize important points, by bolding or "
|
||||
"italicizing the part of text."
|
||||
)
|
||||
|
||||
{"# User Instructions:" if instructions else ""}
|
||||
{instructions or ""}
|
||||
user_instructions = f"# User Instructions:\n{instructions}" if instructions else ""
|
||||
tone_instructions = (
|
||||
f"# Tone Instructions:\nMake slide as {tone} as possible." if tone else ""
|
||||
)
|
||||
|
||||
{"# Tone:" if tone else ""}
|
||||
{tone or ""}
|
||||
verbosity_instructions = ""
|
||||
if verbosity:
|
||||
verbosity_instructions = "# Verbosity Instructions:\n"
|
||||
if verbosity == "concise":
|
||||
verbosity_instructions += "Make slide as concise as possible."
|
||||
elif verbosity == "standard":
|
||||
verbosity_instructions += "Make slide as standard as possible."
|
||||
elif verbosity == "text-heavy":
|
||||
verbosity_instructions += "Make slide as text-heavy as possible."
|
||||
|
||||
{"# Verbosity:" if verbosity else ""}
|
||||
{verbosity or ""}
|
||||
output_fields_instructions = "# Output Fields:\n" + _get_schema_markdown(
|
||||
response_schema
|
||||
)
|
||||
|
||||
# Steps
|
||||
1. Analyze the outline.
|
||||
2. Generate structured slide based on the outline.
|
||||
3. Generate speaker note that is simple, clear, concise and to the point.
|
||||
|
||||
# Notes
|
||||
- Slide body should not use words like "This slide", "This presentation".
|
||||
- Rephrase the slide body to make it flow naturally.
|
||||
- Only use markdown to highlight important points.
|
||||
- Make sure to follow language guidelines.
|
||||
- Speaker note should be normal text, not markdown.
|
||||
- Strictly follow the max and min character limit for every property in the slide.
|
||||
- Never ever go over the max character limit. Limit your narration to make sure you never go over the max character limit.
|
||||
- Number of items should not be more than max number of items specified in slide schema. If you have to put multiple points then merge them to obey max numebr of items.
|
||||
- Generate content as per the given tone.
|
||||
- Be very careful with number of words to generate for given field. As generating more than max characters will overflow in the design. So, analyze early and never generate more characters than allowed.
|
||||
- Do not add emoji in the content.
|
||||
- Metrics should be in abbreviated form with least possible characters. Do not add long sequence of words for metrics.
|
||||
- For verbosity:
|
||||
- If verbosity is 'concise', then generate description as 1/3 or lower of the max character limit. Don't worry if you miss content or context.
|
||||
- If verbosity is 'standard', then generate description as 2/3 of the max character limit.
|
||||
- If verbosity is 'text-heavy', then generate description as 3/4 or higher of the max character limit. Make sure it does not exceed the max character limit.
|
||||
|
||||
User instructions, tone and verbosity should always be followed and should supercede any other instruction, except for max and min character limit, slide schema and number of items.
|
||||
|
||||
- Provide output in json format and **don't include <parameters> tags**.
|
||||
|
||||
# Image and Icon Output Format
|
||||
image: {{
|
||||
__image_prompt__: string,
|
||||
}}
|
||||
icon: {{
|
||||
__icon_query__: string,
|
||||
}}
|
||||
|
||||
"""
|
||||
return SLIDE_CONTENT_SYSTEM_PROMPT.format(
|
||||
markdown_emphasis_rules=markdown_emphasis_rules,
|
||||
user_instructions=user_instructions,
|
||||
tone_instructions=tone_instructions,
|
||||
verbosity_instructions=verbosity_instructions,
|
||||
output_fields_instructions=output_fields_instructions,
|
||||
)
|
||||
|
||||
|
||||
def get_user_prompt(outline: str, language: str):
|
||||
return f"""
|
||||
## Current Date and Time
|
||||
{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
|
||||
|
||||
## Icon Query And Image Prompt Language
|
||||
English
|
||||
|
||||
## Slide Content Language
|
||||
{language}
|
||||
|
||||
## Slide Outline
|
||||
{outline}
|
||||
"""
|
||||
def get_user_prompt(outline: str, language: Optional[str]):
|
||||
return SLIDE_CONTENT_USER_PROMPT.format(
|
||||
current_date_time=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
language=_resolve_prompt_language(language),
|
||||
content=outline,
|
||||
)
|
||||
|
||||
|
||||
def get_messages(
|
||||
outline: str,
|
||||
language: str,
|
||||
language: Optional[str],
|
||||
tone: Optional[str] = None,
|
||||
verbosity: Optional[str] = None,
|
||||
instructions: Optional[str] = None,
|
||||
response_schema: Optional[dict] = None,
|
||||
):
|
||||
|
||||
return [
|
||||
LLMSystemMessage(
|
||||
content=get_system_prompt(tone, verbosity, instructions),
|
||||
content=get_system_prompt(
|
||||
tone,
|
||||
verbosity,
|
||||
instructions,
|
||||
response_schema,
|
||||
),
|
||||
),
|
||||
LLMUserMessage(
|
||||
content=get_user_prompt(outline, language),
|
||||
|
|
@ -101,7 +150,7 @@ def get_messages(
|
|||
async def get_slide_content_from_type_and_outline(
|
||||
slide_layout: SlideLayoutModel,
|
||||
outline: SlideOutlineModel,
|
||||
language: str,
|
||||
language: Optional[str],
|
||||
tone: Optional[str] = None,
|
||||
verbosity: Optional[str] = None,
|
||||
instructions: Optional[str] = None,
|
||||
|
|
@ -125,28 +174,21 @@ async def get_slide_content_from_type_and_outline(
|
|||
True,
|
||||
)
|
||||
|
||||
messages = get_messages(
|
||||
outline.content,
|
||||
language,
|
||||
tone,
|
||||
verbosity,
|
||||
instructions,
|
||||
)
|
||||
print(
|
||||
f"get_slide_content_from_type_and_outline: model={model} outline_len={len(outline.content or '')} language={language}"
|
||||
)
|
||||
try:
|
||||
response = await client.generate_structured(
|
||||
model=model,
|
||||
messages=messages,
|
||||
messages=get_messages(
|
||||
outline.content,
|
||||
language,
|
||||
tone,
|
||||
verbosity,
|
||||
instructions,
|
||||
response_schema,
|
||||
),
|
||||
response_format=response_schema,
|
||||
strict=False,
|
||||
)
|
||||
print(
|
||||
f"get_slide_content_from_type_and_outline: response is None={response is None} keys={list(response.keys())[:6] if isinstance(response, dict) else None}"
|
||||
)
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
print(f"get_slide_content_from_type_and_outline: exception={e}")
|
||||
raise handle_llm_client_exceptions(e)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
from fastapi import HTTPException
|
||||
from google import genai
|
||||
from openai import OpenAI
|
||||
|
||||
from constants.llm import (
|
||||
DEFAULT_ANTHROPIC_MODEL,
|
||||
DEFAULT_CODEX_MODEL,
|
||||
DEFAULT_GOOGLE_MODEL,
|
||||
DEFAULT_OPENAI_MODEL,
|
||||
)
|
||||
|
|
@ -10,9 +13,11 @@ from utils.get_env import (
|
|||
get_anthropic_model_env,
|
||||
get_codex_model_env,
|
||||
get_custom_model_env,
|
||||
get_google_api_key_env,
|
||||
get_google_model_env,
|
||||
get_llm_provider_env,
|
||||
get_ollama_model_env,
|
||||
get_openai_api_key_env,
|
||||
get_openai_model_env,
|
||||
)
|
||||
|
||||
|
|
@ -64,9 +69,28 @@ def get_model():
|
|||
elif selected_llm == LLMProvider.CUSTOM:
|
||||
return get_custom_model_env()
|
||||
elif selected_llm == LLMProvider.CODEX:
|
||||
return get_codex_model_env()
|
||||
return get_codex_model_env() or DEFAULT_CODEX_MODEL
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Invalid LLM provider. Please select one of: openai, google, anthropic, ollama, custom, codex",
|
||||
)
|
||||
|
||||
|
||||
def get_google_llm_client() -> genai.Client:
|
||||
"""Google GenAI client for tests and direct API use (uses GOOGLE_API_KEY from env)."""
|
||||
if not get_google_api_key_env():
|
||||
raise HTTPException(status_code=400, detail="Google API Key is not set")
|
||||
return genai.Client()
|
||||
|
||||
|
||||
def get_llm_client() -> OpenAI:
|
||||
"""OpenAI client for tests and direct API use (uses OPENAI_API_KEY from env)."""
|
||||
if not get_openai_api_key_env():
|
||||
raise HTTPException(status_code=400, detail="OpenAI API Key is not set")
|
||||
return OpenAI()
|
||||
|
||||
|
||||
def get_large_model() -> str:
|
||||
"""Resolved model name for the configured LLM provider (same as runtime `get_model`)."""
|
||||
return get_model()
|
||||
|
|
|
|||
|
|
@ -28,17 +28,211 @@ JWT_CLAIM_PATH = "https://api.openai.com/auth"
|
|||
|
||||
CALLBACK_PORT = 1455
|
||||
|
||||
SUCCESS_HTML = b"""<!doctype html>
|
||||
# Simple branded success page for Presenton authentication
|
||||
SUCCESS_HTML = """<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Authentication successful</title>
|
||||
<title>Presenton – Authentication successful</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text",
|
||||
"Segoe UI", sans-serif;
|
||||
background: radial-gradient(circle at top, #eef2ff 0, #0f172a 55%, #020617 100%);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.card {
|
||||
background: rgba(15, 23, 42, 0.9);
|
||||
border-radius: 18px;
|
||||
padding: 28px 32px 26px;
|
||||
box-shadow:
|
||||
0 18px 45px rgba(15, 23, 42, 0.75),
|
||||
0 0 0 1px rgba(148, 163, 184, 0.2);
|
||||
max-width: 440px;
|
||||
width: 92vw;
|
||||
text-align: center;
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
margin: 4px 0 10px;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
p {
|
||||
margin: 4px 0;
|
||||
font-size: 14px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
background: rgba(22, 163, 74, 0.12);
|
||||
color: #bbf7d0;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.pill-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.25);
|
||||
}
|
||||
.hint {
|
||||
margin-top: 14px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<p>Authentication successful. Return to your terminal / application to continue.</p>
|
||||
<main class="card">
|
||||
<div class="pill">
|
||||
<span class="pill-dot"></span>
|
||||
<span>Authentication successful</span>
|
||||
</div>
|
||||
<h1>You’re all set</h1>
|
||||
<p>You can now return to Presenton to continue.</p>
|
||||
<p class="hint">This window can be safely closed.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>"""
|
||||
</html>""".encode("utf-8")
|
||||
|
||||
STATE_MISMATCH_HTML = """<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Presenton – Authentication issue</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text",
|
||||
"Segoe UI", sans-serif;
|
||||
background: radial-gradient(circle at top, #fef3c7 0, #0f172a 55%, #020617 100%);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.card {
|
||||
background: rgba(15, 23, 42, 0.94);
|
||||
border-radius: 18px;
|
||||
padding: 26px 30px 24px;
|
||||
box-shadow:
|
||||
0 18px 45px rgba(15, 23, 42, 0.78),
|
||||
0 0 0 1px rgba(248, 250, 252, 0.09);
|
||||
max-width: 440px;
|
||||
width: 92vw;
|
||||
text-align: center;
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
h1 {
|
||||
font-size: 18px;
|
||||
margin: 4px 0 8px;
|
||||
color: #fee2e2;
|
||||
}
|
||||
p {
|
||||
margin: 4px 0;
|
||||
font-size: 13px;
|
||||
color: #cbd5f5;
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
background: rgba(239, 68, 68, 0.14);
|
||||
color: #fecaca;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.badge-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: #f97316;
|
||||
box-shadow: 0 0 0 4px rgba(248, 171, 85, 0.32);
|
||||
}
|
||||
button {
|
||||
margin-top: 14px;
|
||||
border-radius: 999px;
|
||||
padding: 7px 16px;
|
||||
border: 0;
|
||||
background: linear-gradient(135deg, #4f46e5, #22c55e);
|
||||
color: #f9fafb;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
box-shadow:
|
||||
0 10px 25px rgba(59, 130, 246, 0.55),
|
||||
0 0 0 1px rgba(15, 23, 42, 0.85);
|
||||
}
|
||||
button:active {
|
||||
transform: translateY(1px);
|
||||
box-shadow:
|
||||
0 4px 16px rgba(59, 130, 246, 0.55),
|
||||
0 0 0 1px rgba(15, 23, 42, 0.85);
|
||||
}
|
||||
.hint {
|
||||
margin-top: 10px;
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
// Gentle auto-reload after a short delay to recover from stale callback windows.
|
||||
setTimeout(function () {
|
||||
try {
|
||||
window.location.reload();
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
}, 2500);
|
||||
function reloadNow() {
|
||||
try {
|
||||
window.location.reload();
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<main class="card">
|
||||
<div class="badge">
|
||||
<span class="badge-dot"></span>
|
||||
<span>We noticed something unexpected</span>
|
||||
</div>
|
||||
<h1>Almost there</h1>
|
||||
<p>We detected a small mismatch while completing authentication.</p>
|
||||
<p>We’ll gently reload this page. If the issue persists, close this window and restart sign-in from Presenton.</p>
|
||||
<button type="button" onclick="reloadNow()">Reload this page</button>
|
||||
<p class="hint">You can also safely close this window and try again from the app.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>""".encode("utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -50,6 +244,7 @@ class TokenSuccess:
|
|||
access: str
|
||||
refresh: str
|
||||
expires: int # Unix ms timestamp when the token expires
|
||||
id_token: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -67,6 +262,14 @@ class AuthorizationFlow:
|
|||
url: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class CodexAccountProfile:
|
||||
account_id: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
is_pro: Optional[bool] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JWT helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -102,6 +305,51 @@ def get_account_id(access_token: str) -> Optional[str]:
|
|||
return None
|
||||
|
||||
|
||||
def _as_non_empty_str(value) -> Optional[str]:
|
||||
if isinstance(value, str):
|
||||
stripped = value.strip()
|
||||
return stripped or None
|
||||
return None
|
||||
|
||||
|
||||
def get_account_profile(access_token: str, id_token: Optional[str] = None) -> CodexAccountProfile:
|
||||
"""Extract profile from exact observed JWT paths in access/id tokens."""
|
||||
access_payload = _decode_jwt_payload(access_token) or {}
|
||||
access_auth = access_payload.get(JWT_CLAIM_PATH)
|
||||
access_auth = access_auth if isinstance(access_auth, dict) else {}
|
||||
|
||||
access_profile = access_payload.get("https://api.openai.com/profile")
|
||||
access_profile = access_profile if isinstance(access_profile, dict) else {}
|
||||
|
||||
id_payload = _decode_jwt_payload(id_token) if id_token else None
|
||||
id_payload = id_payload if isinstance(id_payload, dict) else {}
|
||||
id_auth = id_payload.get(JWT_CLAIM_PATH)
|
||||
id_auth = id_auth if isinstance(id_auth, dict) else {}
|
||||
|
||||
account_id = _as_non_empty_str(access_auth.get("chatgpt_account_id")) or _as_non_empty_str(
|
||||
id_auth.get("chatgpt_account_id")
|
||||
)
|
||||
username = _as_non_empty_str(id_payload.get("name"))
|
||||
email = _as_non_empty_str(access_profile.get("email")) or _as_non_empty_str(
|
||||
id_payload.get("email")
|
||||
)
|
||||
|
||||
plan_type = _as_non_empty_str(access_auth.get("chatgpt_plan_type")) or _as_non_empty_str(
|
||||
id_auth.get("chatgpt_plan_type")
|
||||
)
|
||||
if plan_type:
|
||||
is_pro = plan_type.strip().lower() != "free"
|
||||
else:
|
||||
is_pro = None
|
||||
|
||||
return CodexAccountProfile(
|
||||
account_id=account_id,
|
||||
username=username,
|
||||
email=email,
|
||||
is_pro=is_pro,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Authorization URL + PKCE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -148,22 +396,37 @@ class _CallbackHandler(BaseHTTPRequestHandler):
|
|||
|
||||
expected_state: str = self.server.expected_state # type: ignore[attr-defined]
|
||||
|
||||
if not state_vals or state_vals[0] != expected_state:
|
||||
self.send_response(400)
|
||||
self.end_headers()
|
||||
self.wfile.write(b"State mismatch")
|
||||
return
|
||||
|
||||
if not code_vals:
|
||||
self.send_response(400)
|
||||
self.end_headers()
|
||||
self.wfile.write(b"Missing authorization code")
|
||||
return
|
||||
|
||||
# In the desktop/Electron app context the redirect URI is a localhost-only
|
||||
# callback, so strict CSRF protection via state comparison is less critical.
|
||||
# We've seen intermittent state mismatches in the field (likely from
|
||||
# overlapping auth attempts or stale callback servers), so we treat a
|
||||
# mismatch as a soft warning instead of a hard failure.
|
||||
state_mismatch = bool(state_vals and state_vals[0] != expected_state)
|
||||
if state_mismatch:
|
||||
# Best-effort warning to server logs; handler intentionally continues.
|
||||
try:
|
||||
print(
|
||||
f"[Codex OAuth] State mismatch in callback handler: "
|
||||
f"expected={expected_state} got={state_vals[0]}"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(SUCCESS_HTML)
|
||||
# Show a nicer success page, and a dedicated state-mismatch page that
|
||||
# gently reloads to help recover from stale callback windows.
|
||||
if state_mismatch:
|
||||
self.wfile.write(STATE_MISMATCH_HTML)
|
||||
else:
|
||||
self.wfile.write(SUCCESS_HTML)
|
||||
|
||||
self.server.captured_code = code_vals[0] # type: ignore[attr-defined]
|
||||
|
||||
|
|
@ -265,6 +528,7 @@ def exchange_authorization_code(
|
|||
return TokenFailure(reason=f"HTTP {response.status_code}: {response.text[:200]}")
|
||||
|
||||
body = response.json()
|
||||
|
||||
access = body.get("access_token")
|
||||
refresh = body.get("refresh_token")
|
||||
expires_in = body.get("expires_in")
|
||||
|
|
@ -273,7 +537,9 @@ def exchange_authorization_code(
|
|||
return TokenFailure(reason=f"Token response missing fields: {list(body.keys())}")
|
||||
|
||||
expires_ms = int(time.time() * 1000) + int(expires_in) * 1000
|
||||
return TokenSuccess(access=access, refresh=refresh, expires=expires_ms)
|
||||
id_token = body.get("id_token")
|
||||
id_token = id_token if isinstance(id_token, str) else None
|
||||
return TokenSuccess(access=access, refresh=refresh, expires=expires_ms, id_token=id_token)
|
||||
except Exception as exc:
|
||||
return TokenFailure(reason=str(exc))
|
||||
|
||||
|
|
|
|||
205
servers/fastapi/utils/outline_utils.py
Normal file
205
servers/fastapi/utils/outline_utils.py
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import math
|
||||
import re
|
||||
from typing import Iterable, List, Optional
|
||||
|
||||
from models.presentation_outline_model import (
|
||||
PresentationOutlineModel,
|
||||
SlideOutlineModel,
|
||||
)
|
||||
|
||||
|
||||
HEADING_PATTERN = re.compile(r"^\s{0,3}#+\s*(.+)$", re.MULTILINE)
|
||||
FIRST_SENTENCE_PATTERN = re.compile(r"^\s*([^.?!]+?[.?!])", re.DOTALL)
|
||||
IMAGE_URL_PATTERN = re.compile(
|
||||
r"https?://[-\w./%~:!$&'()*+,;=]+?\.(?:jpe?g|png|webp)(?:\?[^\s\"\'\\]*)?",
|
||||
re.IGNORECASE | re.UNICODE,
|
||||
)
|
||||
|
||||
|
||||
def get_presentation_title_from_presentation_outline(
|
||||
presentation_outline: PresentationOutlineModel,
|
||||
) -> str:
|
||||
if not presentation_outline.slides:
|
||||
return "Untitled Presentation"
|
||||
|
||||
first_content = presentation_outline.slides[0].content or ""
|
||||
|
||||
if re.match(r"^\s*#{1,6}\s*Page\s+\d+\b", first_content):
|
||||
first_content = re.sub(
|
||||
r"^\s*#{1,6}\s*Page\s+\d+\b[\s,:\-]*",
|
||||
"",
|
||||
first_content,
|
||||
count=1,
|
||||
)
|
||||
|
||||
return (
|
||||
first_content[:100]
|
||||
.replace("#", "")
|
||||
.replace("/", "")
|
||||
.replace("\\", "")
|
||||
.replace("\n", " ")
|
||||
)
|
||||
|
||||
|
||||
def _get_toc_count_for_total_slides(total_slides: int, title_slide: bool) -> int:
|
||||
if total_slides <= 0:
|
||||
return 0
|
||||
|
||||
first_pass = math.ceil(((total_slides - 1) if title_slide else total_slides) / 10)
|
||||
return math.ceil((total_slides - first_pass) / 10)
|
||||
|
||||
|
||||
def get_no_of_toc_required_for_n_outlines(
|
||||
*,
|
||||
n_outlines: int,
|
||||
title_slide: bool,
|
||||
target_total_slides: Optional[int] = None,
|
||||
) -> int:
|
||||
if target_total_slides is not None:
|
||||
adjusted_total = max(target_total_slides, n_outlines)
|
||||
return _get_toc_count_for_total_slides(adjusted_total, title_slide)
|
||||
|
||||
if n_outlines <= 0:
|
||||
return 0
|
||||
|
||||
return math.ceil(((n_outlines - 1) if title_slide else n_outlines) / 10)
|
||||
|
||||
|
||||
def get_no_of_outlines_to_generate_for_n_slides(
|
||||
*,
|
||||
n_slides: int,
|
||||
toc: bool,
|
||||
title_slide: bool,
|
||||
) -> int:
|
||||
if toc:
|
||||
n_toc_1 = math.ceil(((n_slides - 1) if title_slide else n_slides) / 10)
|
||||
n_toc_2 = math.ceil((n_slides - n_toc_1) / 10)
|
||||
|
||||
return n_slides - n_toc_2
|
||||
|
||||
else:
|
||||
return n_slides
|
||||
|
||||
|
||||
def get_presentation_outline_model_with_toc(
|
||||
*,
|
||||
outline: PresentationOutlineModel,
|
||||
n_toc_slides: int,
|
||||
title_slide: bool,
|
||||
) -> PresentationOutlineModel:
|
||||
if n_toc_slides <= 0:
|
||||
return outline
|
||||
|
||||
outline_with_toc = outline.model_copy(deep=True)
|
||||
insertion_index = 1 if title_slide else 0
|
||||
|
||||
existing_outlines = outline_with_toc.slides
|
||||
outlines_for_toc = existing_outlines[insertion_index:]
|
||||
if not outlines_for_toc:
|
||||
return outline_with_toc
|
||||
|
||||
sections = _split_outlines_evenly(outlines_for_toc, n_toc_slides)
|
||||
if not sections:
|
||||
return outline_with_toc
|
||||
|
||||
toc_slides: List[SlideOutlineModel] = []
|
||||
outlines_before_toc = 1 if title_slide else 0
|
||||
total_toc_slides = len(sections)
|
||||
global_outline_index = 0
|
||||
|
||||
for section_index, section in enumerate(sections):
|
||||
section_lines = [
|
||||
"## Table of Contents",
|
||||
"",
|
||||
]
|
||||
|
||||
for outline in section:
|
||||
outline_title = _extract_outline_title(outline.content)
|
||||
page_number = (
|
||||
outlines_before_toc + total_toc_slides + global_outline_index + 1
|
||||
)
|
||||
section_lines.append(
|
||||
f"- Page number: {page_number}, Title: {outline_title}"
|
||||
)
|
||||
global_outline_index += 1
|
||||
|
||||
toc_slides.append(
|
||||
SlideOutlineModel(
|
||||
content="\n".join(
|
||||
line for line in section_lines if line is not None
|
||||
).strip()
|
||||
)
|
||||
)
|
||||
|
||||
for offset, toc_slide in enumerate(toc_slides):
|
||||
existing_outlines.insert(insertion_index + offset, toc_slide)
|
||||
|
||||
return outline_with_toc
|
||||
|
||||
|
||||
def _split_outlines_evenly(
|
||||
outlines: Iterable[SlideOutlineModel], n_sections: int
|
||||
) -> List[List[SlideOutlineModel]]:
|
||||
"""Split outlines into n contiguous sections with near-equal sizes."""
|
||||
outlines_list = list(outlines)
|
||||
if n_sections <= 0 or not outlines_list:
|
||||
return []
|
||||
|
||||
total = len(outlines_list)
|
||||
n_sections = max(1, n_sections)
|
||||
base_size = total // n_sections
|
||||
remainder = total % n_sections
|
||||
|
||||
sections: List[List[SlideOutlineModel]] = []
|
||||
start = 0
|
||||
for section_index in range(n_sections):
|
||||
current_size = base_size + (1 if section_index < remainder else 0)
|
||||
end = start + current_size
|
||||
sections.append(outlines_list[start:end])
|
||||
start = end
|
||||
|
||||
return sections
|
||||
|
||||
|
||||
def _extract_outline_title(content: str) -> str:
|
||||
"""Get a human-friendly title from an outline's markdown content."""
|
||||
text = content or ""
|
||||
|
||||
heading_match = HEADING_PATTERN.search(text)
|
||||
if heading_match:
|
||||
return heading_match.group(1).strip()
|
||||
|
||||
sentence_match = FIRST_SENTENCE_PATTERN.search(text.strip())
|
||||
if sentence_match:
|
||||
return sentence_match.group(1).strip()
|
||||
|
||||
for line in text.splitlines():
|
||||
stripped_line = line.strip()
|
||||
if stripped_line:
|
||||
return stripped_line
|
||||
|
||||
return "Slide"
|
||||
|
||||
|
||||
def get_images_for_slides_from_outline(
|
||||
slides: List[SlideOutlineModel],
|
||||
) -> List[List[str]]:
|
||||
"""
|
||||
Extract image URLs (png, jpg, jpeg, webp) from each slide's content in the outline.
|
||||
|
||||
Args:
|
||||
outline: PresentationOutlineModel containing slides with content
|
||||
|
||||
Returns:
|
||||
List of lists of image URLs, one list per slide
|
||||
"""
|
||||
result: List[List[str]] = []
|
||||
|
||||
for slide in slides:
|
||||
content = slide.content or ""
|
||||
image_urls = IMAGE_URL_PATTERN.findall(content)
|
||||
# Remove duplicates while preserving order
|
||||
unique_urls = list(dict.fromkeys(image_urls))
|
||||
result.append(unique_urls)
|
||||
|
||||
return result
|
||||
21
servers/fastapi/utils/path_helpers.py
Normal file
21
servers/fastapi/utils/path_helpers.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
"""Paths relative to the FastAPI process working directory (Docker / local dev).
|
||||
|
||||
The API is always started with cwd set to the `servers/fastapi` package root
|
||||
(see start.js). No Electron, PyInstaller, or OS-specific layout handling.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def get_resource_path(relative_path: str) -> str:
|
||||
"""Absolute path to bundled read-only assets (e.g. ``static/``, ``assets/``)."""
|
||||
return os.path.abspath(os.path.join(os.getcwd(), relative_path))
|
||||
|
||||
|
||||
def get_writable_path(relative_path: str) -> str:
|
||||
"""Absolute path under cwd for caches and generated files; ensures the directory exists."""
|
||||
path = os.path.abspath(os.path.join(os.getcwd(), relative_path))
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import asyncio
|
||||
from typing import List, Tuple
|
||||
import os
|
||||
from typing import List, Optional, Tuple
|
||||
from models.image_prompt import ImagePrompt
|
||||
from models.sql.image_asset import ImageAsset
|
||||
from models.sql.slide import SlideModel
|
||||
|
|
@ -7,20 +8,33 @@ from services.icon_finder_service import ICON_FINDER_SERVICE
|
|||
from services.image_generation_service import ImageGenerationService
|
||||
from utils.asset_directory_utils import get_images_directory
|
||||
from utils.dict_utils import get_dict_at_path, get_dict_paths_with_key, set_dict_at_path
|
||||
from utils.path_helpers import get_resource_path
|
||||
|
||||
|
||||
async def process_slide_and_fetch_assets(
|
||||
image_generation_service: ImageGenerationService,
|
||||
slide: SlideModel,
|
||||
outline_image_urls: Optional[List[str]] = None,
|
||||
) -> List[ImageAsset]:
|
||||
|
||||
async_tasks = []
|
||||
async_task_meta = []
|
||||
|
||||
image_paths = get_dict_paths_with_key(slide.content, "__image_prompt__")
|
||||
icon_paths = get_dict_paths_with_key(slide.content, "__icon_query__")
|
||||
|
||||
for image_path in image_paths:
|
||||
for image_index, image_path in enumerate(image_paths):
|
||||
__image_prompt__parent = get_dict_at_path(slide.content, image_path)
|
||||
|
||||
if (
|
||||
outline_image_urls
|
||||
and image_index < len(outline_image_urls)
|
||||
and outline_image_urls[image_index]
|
||||
):
|
||||
__image_prompt__parent["__image_url__"] = outline_image_urls[image_index]
|
||||
set_dict_at_path(slide.content, image_path, __image_prompt__parent)
|
||||
continue
|
||||
|
||||
async_tasks.append(
|
||||
image_generation_service.generate_image(
|
||||
ImagePrompt(
|
||||
|
|
@ -28,36 +42,37 @@ async def process_slide_and_fetch_assets(
|
|||
)
|
||||
)
|
||||
)
|
||||
async_task_meta.append(("image", image_path))
|
||||
|
||||
for icon_path in icon_paths:
|
||||
__icon_query__parent = get_dict_at_path(slide.content, icon_path)
|
||||
async_tasks.append(
|
||||
ICON_FINDER_SERVICE.search_icons(__icon_query__parent["__icon_query__"])
|
||||
)
|
||||
async_task_meta.append(("icon", icon_path))
|
||||
|
||||
results = await asyncio.gather(*async_tasks)
|
||||
results.reverse()
|
||||
results = await asyncio.gather(*async_tasks) if async_tasks else []
|
||||
|
||||
return_assets = []
|
||||
for image_path in image_paths:
|
||||
image_dict = get_dict_at_path(slide.content, image_path)
|
||||
result = results.pop()
|
||||
if isinstance(result, ImageAsset):
|
||||
return_assets.append(result)
|
||||
image_dict["__image_url__"] = result.path
|
||||
else:
|
||||
image_dict["__image_url__"] = result
|
||||
set_dict_at_path(slide.content, image_path, image_dict)
|
||||
for (task_type, asset_path), result in zip(async_task_meta, results):
|
||||
if task_type == "image":
|
||||
image_dict = get_dict_at_path(slide.content, asset_path)
|
||||
if isinstance(result, ImageAsset):
|
||||
return_assets.append(result)
|
||||
image_dict["__image_url__"] = result.file_url
|
||||
else:
|
||||
image_dict["__image_url__"] = result
|
||||
set_dict_at_path(slide.content, asset_path, image_dict)
|
||||
continue
|
||||
|
||||
for icon_path in icon_paths:
|
||||
icon_dict = get_dict_at_path(slide.content, icon_path)
|
||||
icon_result = results.pop()
|
||||
if icon_result and len(icon_result) > 0:
|
||||
icon_dict["__icon_url__"] = icon_result[0]
|
||||
icon_dict = get_dict_at_path(slide.content, asset_path)
|
||||
# ICON_FINDER_SERVICE.search_icons returns a list of URLs
|
||||
if isinstance(result, list) and result:
|
||||
icon_dict["__icon_url__"] = result[0]
|
||||
else:
|
||||
# Fallback to placeholder if no icon found
|
||||
# Fallback to FastAPI static placeholder if no icon found
|
||||
icon_dict["__icon_url__"] = "/static/icons/placeholder.svg"
|
||||
set_dict_at_path(slide.content, icon_path, icon_dict)
|
||||
set_dict_at_path(slide.content, asset_path, icon_dict)
|
||||
|
||||
return return_assets
|
||||
|
||||
|
|
@ -152,17 +167,17 @@ async def process_old_and_new_slides_and_fetch_assets(
|
|||
new_assets = []
|
||||
|
||||
# Sets new image and icon urls for assets that were fetched
|
||||
for i, new_image in enumerate(new_images):
|
||||
for i, _ in enumerate(new_images):
|
||||
if new_images_fetch_status[i]:
|
||||
fetched_image = new_images[i]
|
||||
if isinstance(fetched_image, ImageAsset):
|
||||
new_assets.append(fetched_image)
|
||||
image_url = fetched_image.path
|
||||
image_url = fetched_image.file_url
|
||||
else:
|
||||
image_url = fetched_image
|
||||
new_image_dicts[i]["__image_url__"] = image_url
|
||||
|
||||
for i, new_icon in enumerate(new_icons):
|
||||
for i, _ in enumerate(new_icons):
|
||||
if new_icons_fetch_status[i]:
|
||||
icon_result = new_icons[i]
|
||||
if icon_result and len(icon_result) > 0:
|
||||
|
|
@ -187,10 +202,12 @@ def process_slide_add_placeholder_assets(slide: SlideModel):
|
|||
|
||||
for image_path in image_paths:
|
||||
image_dict = get_dict_at_path(slide.content, image_path)
|
||||
# Use FastAPI static path for placeholder image
|
||||
image_dict["__image_url__"] = "/static/images/placeholder.jpg"
|
||||
set_dict_at_path(slide.content, image_path, image_dict)
|
||||
|
||||
for icon_path in icon_paths:
|
||||
icon_dict = get_dict_at_path(slide.content, icon_path)
|
||||
# Use FastAPI static path for placeholder icon
|
||||
icon_dict["__icon_url__"] = "/static/icons/placeholder.svg"
|
||||
set_dict_at_path(slide.content, icon_path, icon_dict)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from copy import deepcopy
|
||||
from typing import Any, List, Mapping, Union
|
||||
from typing import Any, List
|
||||
|
||||
from openai import NOT_GIVEN
|
||||
|
||||
|
|
@ -22,51 +22,6 @@ supported_string_formats = [
|
|||
]
|
||||
|
||||
|
||||
def _is_json_object(value: object) -> bool:
|
||||
"""True if value is a dict-like object but not a list."""
|
||||
return isinstance(value, Mapping) and not isinstance(value, list)
|
||||
|
||||
|
||||
def _convert_pydantic_schema(schema: object) -> dict | None:
|
||||
"""Return JSON schema dict from a Pydantic model or class, else None."""
|
||||
if BaseModel is None:
|
||||
return None
|
||||
if isinstance(schema, BaseModel):
|
||||
return schema.model_json_schema()
|
||||
if isinstance(schema, type) and issubclass(schema, BaseModel):
|
||||
return schema.model_json_schema()
|
||||
if hasattr(schema, "model_json_schema"):
|
||||
try:
|
||||
return getattr(schema, "model_json_schema")()
|
||||
except TypeError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def normalize_output_schema(
|
||||
schema: Union[dict, type, object] | None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
Normalize output schema to a plain JSON schema dict (SDK-style).
|
||||
Accepts a JSON schema dict, a Pydantic model class, or a Pydantic instance.
|
||||
Returns None if schema is None; otherwise returns a dict suitable for
|
||||
ResponseSchema / structured output.
|
||||
"""
|
||||
if schema is None:
|
||||
return None
|
||||
|
||||
converted = _convert_pydantic_schema(schema)
|
||||
if converted is not None:
|
||||
return converted
|
||||
|
||||
if not _is_json_object(schema):
|
||||
raise ValueError(
|
||||
"output_schema must be a plain JSON object (dict) or a Pydantic model"
|
||||
)
|
||||
|
||||
return dict(schema)
|
||||
|
||||
|
||||
def remove_fields_from_schema(schema: dict, fields_to_remove: List[str]):
|
||||
schema = deepcopy(schema)
|
||||
properties_paths = get_dict_paths_with_key(schema, "properties")
|
||||
|
|
@ -179,12 +134,12 @@ def ensure_strict_json_schema(
|
|||
|
||||
# arrays
|
||||
# { 'type': 'array', 'items': {...} }
|
||||
# OpenAI requires array schemas to have "items". Zod tuples may emit prefixItems only.
|
||||
items = json_schema.get("items")
|
||||
if isinstance(items, dict):
|
||||
json_schema["items"] = ensure_strict_json_schema(
|
||||
items, path=(*path, "items"), root=root
|
||||
)
|
||||
|
||||
elif typ == "array":
|
||||
prefix_items = json_schema.get("prefixItems")
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -122,5 +122,17 @@ def set_codex_account_id_env(value: str):
|
|||
os.environ["CODEX_ACCOUNT_ID"] = value
|
||||
|
||||
|
||||
def set_codex_username_env(value: str):
|
||||
os.environ["CODEX_USERNAME"] = value
|
||||
|
||||
|
||||
def set_codex_email_env(value: str):
|
||||
os.environ["CODEX_EMAIL"] = value
|
||||
|
||||
|
||||
def set_codex_is_pro_env(value: str):
|
||||
os.environ["CODEX_IS_PRO"] = value
|
||||
|
||||
|
||||
def set_codex_model_env(value: str):
|
||||
os.environ["CODEX_MODEL"] = value
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ from utils.get_env import (
|
|||
get_codex_refresh_token_env,
|
||||
get_codex_token_expires_env,
|
||||
get_codex_account_id_env,
|
||||
get_codex_username_env,
|
||||
get_codex_email_env,
|
||||
get_codex_is_pro_env,
|
||||
get_codex_model_env,
|
||||
)
|
||||
from utils.parsers import parse_bool_or_none
|
||||
|
|
@ -64,6 +67,9 @@ from utils.set_env import (
|
|||
set_codex_refresh_token_env,
|
||||
set_codex_token_expires_env,
|
||||
set_codex_account_id_env,
|
||||
set_codex_username_env,
|
||||
set_codex_email_env,
|
||||
set_codex_is_pro_env,
|
||||
set_codex_model_env,
|
||||
)
|
||||
|
||||
|
|
@ -133,6 +139,13 @@ def get_user_config():
|
|||
CODEX_REFRESH_TOKEN=existing_config.CODEX_REFRESH_TOKEN or get_codex_refresh_token_env(),
|
||||
CODEX_TOKEN_EXPIRES=existing_config.CODEX_TOKEN_EXPIRES or get_codex_token_expires_env(),
|
||||
CODEX_ACCOUNT_ID=existing_config.CODEX_ACCOUNT_ID or get_codex_account_id_env(),
|
||||
CODEX_USERNAME=existing_config.CODEX_USERNAME or get_codex_username_env(),
|
||||
CODEX_EMAIL=existing_config.CODEX_EMAIL or get_codex_email_env(),
|
||||
CODEX_IS_PRO=(
|
||||
existing_config.CODEX_IS_PRO
|
||||
if existing_config.CODEX_IS_PRO is not None
|
||||
else parse_bool_or_none(get_codex_is_pro_env())
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -196,6 +209,12 @@ def update_env_with_user_config():
|
|||
set_codex_token_expires_env(user_config.CODEX_TOKEN_EXPIRES)
|
||||
if user_config.CODEX_ACCOUNT_ID:
|
||||
set_codex_account_id_env(user_config.CODEX_ACCOUNT_ID)
|
||||
if user_config.CODEX_USERNAME:
|
||||
set_codex_username_env(user_config.CODEX_USERNAME)
|
||||
if user_config.CODEX_EMAIL:
|
||||
set_codex_email_env(user_config.CODEX_EMAIL)
|
||||
if user_config.CODEX_IS_PRO is not None:
|
||||
set_codex_is_pro_env(str(user_config.CODEX_IS_PRO))
|
||||
|
||||
|
||||
def save_codex_tokens_to_user_config() -> None:
|
||||
|
|
@ -220,6 +239,9 @@ def save_codex_tokens_to_user_config() -> None:
|
|||
existing["CODEX_REFRESH_TOKEN"] = get_codex_refresh_token_env()
|
||||
existing["CODEX_TOKEN_EXPIRES"] = get_codex_token_expires_env()
|
||||
existing["CODEX_ACCOUNT_ID"] = get_codex_account_id_env()
|
||||
existing["CODEX_USERNAME"] = get_codex_username_env()
|
||||
existing["CODEX_EMAIL"] = get_codex_email_env()
|
||||
existing["CODEX_IS_PRO"] = parse_bool_or_none(get_codex_is_pro_env())
|
||||
|
||||
try:
|
||||
with open(user_config_path, "w") as f:
|
||||
|
|
|
|||
|
|
@ -1,9 +1,25 @@
|
|||
from pathlib import Path
|
||||
from typing import List
|
||||
from fastapi import HTTPException
|
||||
|
||||
from fastapi import UploadFile
|
||||
|
||||
|
||||
def _is_accepted_file_type(file: UploadFile, accepted_types: List[str]) -> bool:
|
||||
accepted_mime_types = {t.lower() for t in accepted_types if not t.startswith(".")}
|
||||
accepted_extensions = {t.lower() for t in accepted_types if t.startswith(".")}
|
||||
|
||||
content_type = (file.content_type or "").strip().lower()
|
||||
if content_type in accepted_mime_types:
|
||||
return True
|
||||
|
||||
extension = Path(file.filename or "").suffix.lower()
|
||||
if extension in accepted_extensions:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def validate_files(
|
||||
field,
|
||||
nullable: bool,
|
||||
|
|
@ -15,12 +31,14 @@ def validate_files(
|
|||
if field:
|
||||
files: List[UploadFile] = field if multiple else [field]
|
||||
for each_file in files:
|
||||
if (max_size * 1024 * 1024) < each_file.size:
|
||||
file_size = each_file.size or 0
|
||||
|
||||
if (max_size * 1024 * 1024) < file_size:
|
||||
raise HTTPException(
|
||||
400,
|
||||
detail=f"File '{each_file.filename}' exceeded max upload size of {max_size} MB",
|
||||
)
|
||||
elif each_file.content_type not in accepted_types:
|
||||
elif not _is_accepted_file_type(each_file, accepted_types):
|
||||
raise HTTPException(
|
||||
400,
|
||||
detail=f"File '{each_file.filename}' not accepted. Accepted types: {accepted_types}",
|
||||
|
|
|
|||
92
servers/fastapi/uv.lock
generated
92
servers/fastapi/uv.lock
generated
|
|
@ -770,6 +770,40 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/e5/a6/5aa862489a2918a096166fd98d9fe86b7fd53c607678b3fa9d8c432d88d5/fastapi_cloud_cli-0.1.5-py3-none-any.whl", hash = "sha256:d80525fb9c0e8af122370891f9fa83cf5d496e4ad47a8dd26c0496a6c85a012a", size = 18992, upload-time = "2025-07-28T13:30:47.427Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastembed"
|
||||
version = "0.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "huggingface-hub" },
|
||||
{ name = "loguru" },
|
||||
{ name = "mmh3" },
|
||||
{ name = "numpy" },
|
||||
{ name = "onnxruntime" },
|
||||
{ name = "pillow" },
|
||||
{ name = "py-rust-stemmers" },
|
||||
{ name = "requests" },
|
||||
{ name = "tokenizers" },
|
||||
{ name = "tqdm" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/26/25/58865e36b6e8a9a0d0ff905b5601aa30db97956327c0df42ec4ed6accc21/fastembed-0.8.0.tar.gz", hash = "sha256:75966edfa8b006ee78514c726bd7f6a50721dadc89305279052be9db72fd53e8", size = 75115, upload-time = "2026-03-23T16:34:41.699Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/e8/26b7d78bb8972498c467ca34cb12ee2e60d26ba5eae6d8443189a1af37a5/fastembed-0.8.0-py3-none-any.whl", hash = "sha256:40bee672657574a1009e35ec50030a55f2b426842cb011845379817641bbbbd0", size = 116572, upload-time = "2026-03-23T16:34:40.69Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastembed-vectorstore"
|
||||
version = "0.5.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "fastembed" },
|
||||
{ name = "numpy" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/18/d9/02b99231016586081f6f1200ebf592a64ea860b1c37403a50ad0b6c7db21/fastembed_vectorstore-0.5.3.tar.gz", hash = "sha256:0c00aa9886840d77f11e637185c34edc7ec3e1aa3e319dd4c3268141d21aec8e", size = 4813, upload-time = "2026-02-18T18:49:16.338Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/33/2e28641dd141bd1002d71b8f604076f0201a675ec50ae969422ef1556230/fastembed_vectorstore-0.5.3-py3-none-any.whl", hash = "sha256:d4e3fb506fcc02c46373ad5dcac829f52a988f422f3354281622c6128bb3cc89", size = 5753, upload-time = "2026-02-18T18:49:14.868Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastmcp"
|
||||
version = "2.11.0"
|
||||
|
|
@ -1258,6 +1292,19 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/e7/1e/fb441c07b6662ec1fc92b249225ba6e6e5221b05623cb0131d082f782edc/lazy_object_proxy-1.11.0-py3-none-any.whl", hash = "sha256:a56a5093d433341ff7da0e89f9b486031ccd222ec8e52ec84d0ec1cdc819674b", size = 16635, upload-time = "2025-04-16T16:53:47.198Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "loguru"
|
||||
version = "0.7.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "win32-setctime", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lxml"
|
||||
version = "5.4.0"
|
||||
|
|
@ -1946,6 +1993,7 @@ dependencies = [
|
|||
{ name = "dirtyjson" },
|
||||
{ name = "docling" },
|
||||
{ name = "fastapi", extra = ["standard"] },
|
||||
{ name = "fastembed-vectorstore" },
|
||||
{ name = "fastmcp" },
|
||||
{ name = "google-genai" },
|
||||
{ name = "nltk" },
|
||||
|
|
@ -1970,6 +2018,7 @@ requires-dist = [
|
|||
{ name = "dirtyjson", specifier = ">=1.0.8" },
|
||||
{ name = "docling", specifier = ">=2.43.0" },
|
||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.116.1" },
|
||||
{ name = "fastembed-vectorstore", specifier = ">=0.5.2" },
|
||||
{ name = "fastmcp", specifier = ">=2.11.0" },
|
||||
{ name = "google-genai", specifier = ">=1.28.0" },
|
||||
{ name = "nltk", specifier = ">=3.9.1" },
|
||||
|
|
@ -2036,6 +2085,24 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/50/1b/6921afe68c74868b4c9fa424dad3be35b095e16687989ebbb50ce4fceb7c/psutil-7.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:4cf3d4eb1aa9b348dec30105c55cd9b7d4629285735a102beb4441e38db90553", size = 244885, upload-time = "2025-02-13T21:54:37.486Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "py-rust-stemmers"
|
||||
version = "0.1.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/63/4fbc14810c32d2a884e2e94e406a7d5bf8eee53e1103f558433817230342/py_rust_stemmers-0.1.5.tar.gz", hash = "sha256:e9c310cfb5c2470d7c7c8a0484725965e7cab8b1237e106a0863d5741da3e1f7", size = 9388, upload-time = "2025-02-19T13:56:28.708Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/36/9b/6b11f843c01d110db58a68ec4176cb77b37f03268831742a7241f4810fe4/py_rust_stemmers-0.1.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:e644987edaf66919f5a9e4693336930f98d67b790857890623a431bb77774c84", size = 286085, upload-time = "2025-02-19T13:55:08.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/d1/e16b587dc0ebc42916b1caad994bc37fbb19ad2c7e3f5f3a586ba2630c16/py_rust_stemmers-0.1.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:910d87d39ba75da1fe3d65df88b926b4b454ada8d73893cbd36e258a8a648158", size = 272019, upload-time = "2025-02-19T13:55:10.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/66/8777f125720acb896b336e6f8153e3ec39754563bc9b89523cfe06ba63da/py_rust_stemmers-0.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:31ff4fb9417cec35907c18a6463e3d5a4941a5aa8401f77fbb4156b3ada69e3f", size = 310547, upload-time = "2025-02-19T13:55:11.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/f5/b79249c787c59b9ce2c5d007c0a0dc0fc1ecccfcf98a546c131cca55899e/py_rust_stemmers-0.1.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07b3b8582313ef8a7f544acf2c887f27c3dd48c5ddca028fa0f498de7380e24f", size = 315238, upload-time = "2025-02-19T13:55:13.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/4c/c05c266ed74c063ae31dc5633ed63c48eb3b78034afcc80fe755d0cb09e7/py_rust_stemmers-0.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:804944eeb5c5559443d81f30c34d6e83c6292d72423f299e42f9d71b9d240941", size = 324420, upload-time = "2025-02-19T13:55:15.292Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/65/feb83af28095397466e6e031989ff760cc89b01e7da169e76d4cf16a2252/py_rust_stemmers-0.1.5-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:c52c5c326de78c70cfc71813fa56818d1bd4894264820d037d2be0e805b477bd", size = 324791, upload-time = "2025-02-19T13:55:16.45Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/3e/162be2f9c1c383e66e510218d9d4946c8a84ee92c64f6d836746540e915f/py_rust_stemmers-0.1.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8f374c0f26ef35fb87212686add8dff394bcd9a1364f14ce40fe11504e25e30", size = 488014, upload-time = "2025-02-19T13:55:18.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/ee/ed09ce6fde1eefe50aa13a8a8533aa7ebe3cc096d1a43155cc71ba28d298/py_rust_stemmers-0.1.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:0ae0540453843bc36937abb54fdbc0d5d60b51ef47aa9667afd05af9248e09eb", size = 575581, upload-time = "2025-02-19T13:55:19.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/31/2a48960a072e54d7cc244204d98854d201078e1bb5c68a7843a3f6d21ced/py_rust_stemmers-0.1.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85944262c248ea30444155638c9e148a3adc61fe51cf9a3705b4055b564ec95d", size = 493269, upload-time = "2025-02-19T13:55:21.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/33/872269c10ca35b00c5376159a2a0611a0f96372be16b616b46b3d59d09fe/py_rust_stemmers-0.1.5-cp311-none-win_amd64.whl", hash = "sha256:147234020b3eefe6e1a962173e41d8cf1dbf5d0689f3cd60e3022d1ac5c2e203", size = 209399, upload-time = "2025-02-19T13:55:22.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1"
|
||||
version = "0.6.1"
|
||||
|
|
@ -2909,7 +2976,7 @@ dependencies = [
|
|||
{ name = "typing-extensions", marker = "sys_platform == 'darwin'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://download.pytorch.org/whl/cpu/torch-2.7.1-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:68a352c7f435abb5cb47e2c032dcd1012772ae2bacb6fc8b83b0c1b11874ab3a" },
|
||||
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.7.1-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:68a352c7f435abb5cb47e2c032dcd1012772ae2bacb6fc8b83b0c1b11874ab3a" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2929,9 +2996,9 @@ dependencies = [
|
|||
{ name = "typing-extensions", marker = "sys_platform != 'darwin'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://download.pytorch.org/whl/cpu/torch-2.7.1%2Bcpu-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:5fe6045b8f426bf2d0426e4fe009f1667a954ec2aeb82f1bd0bf60c6d7a85445" },
|
||||
{ url = "https://download.pytorch.org/whl/cpu/torch-2.7.1%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a1684793e352f03fa14f78857e55d65de4ada8405ded1da2bf4f452179c4b779" },
|
||||
{ url = "https://download.pytorch.org/whl/cpu/torch-2.7.1%2Bcpu-cp311-cp311-win_amd64.whl", hash = "sha256:7b977eccbc85ae2bd19d6998de7b1f1f4bd3c04eaffd3015deb7934389783399" },
|
||||
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.7.1%2Bcpu-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:5fe6045b8f426bf2d0426e4fe009f1667a954ec2aeb82f1bd0bf60c6d7a85445" },
|
||||
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.7.1%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a1684793e352f03fa14f78857e55d65de4ada8405ded1da2bf4f452179c4b779" },
|
||||
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.7.1%2Bcpu-cp311-cp311-win_amd64.whl", hash = "sha256:7b977eccbc85ae2bd19d6998de7b1f1f4bd3c04eaffd3015deb7934389783399" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2949,8 +3016,8 @@ dependencies = [
|
|||
{ name = "torch", version = "2.7.1+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://download.pytorch.org/whl/cpu/torchvision-0.22.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4addf626e2b57fc22fd6d329cf1346d474497672e6af8383b7b5b636fba94a53" },
|
||||
{ url = "https://download.pytorch.org/whl/cpu/torchvision-0.22.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:8b4a53a6067d63adba0c52f2b8dd2290db649d642021674ee43c0c922f0c6a69" },
|
||||
{ url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.22.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4addf626e2b57fc22fd6d329cf1346d474497672e6af8383b7b5b636fba94a53" },
|
||||
{ url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.22.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:8b4a53a6067d63adba0c52f2b8dd2290db649d642021674ee43c0c922f0c6a69" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2966,8 +3033,8 @@ dependencies = [
|
|||
{ name = "torch", version = "2.7.1+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://download.pytorch.org/whl/cpu/torchvision-0.22.1%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:4e0cbc165a472605d0c13da68ae22e84b17a6b815d5e600834777823e1bcb658" },
|
||||
{ url = "https://download.pytorch.org/whl/cpu/torchvision-0.22.1%2Bcpu-cp311-cp311-win_amd64.whl", hash = "sha256:9482adee074f60a45fd69892f7488281aadfda7836948c94b0a9b0caf55d1d67" },
|
||||
{ url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.22.1%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:4e0cbc165a472605d0c13da68ae22e84b17a6b815d5e600834777823e1bcb658" },
|
||||
{ url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.22.1%2Bcpu-cp311-cp311-win_amd64.whl", hash = "sha256:9482adee074f60a45fd69892f7488281aadfda7836948c94b0a9b0caf55d1d67" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3164,6 +3231,15 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/ee/ea/c67e1dee1ba208ed22c06d1d547ae5e293374bfc43e0eb0ef5e262b68561/werkzeug-3.1.1-py3-none-any.whl", hash = "sha256:a71124d1ef06008baafa3d266c02f56e1836a5984afd6dd6c9230669d60d9fb5", size = 224371, upload-time = "2024-11-01T16:40:43.994Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "win32-setctime"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xlsxwriter"
|
||||
version = "3.2.5"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
import * as z from "zod";
|
||||
|
||||
export const slideLayoutId = "product-overview-market-opportunity-slide";
|
||||
export const slideLayoutName = "Product Overview Market Opportunity Slide";
|
||||
export const slideLayoutDescription =
|
||||
"A market opportunity slide with title and intro text on the left, four bullet lines extending toward the right, and concentric value circles as the visual focal point.";
|
||||
|
||||
const BulletSchema = z.object({
|
||||
text: z.string().min(12).max(46).meta({
|
||||
description: "Bullet text shown on the left side of a line.",
|
||||
}),
|
||||
});
|
||||
|
||||
export const Schema = z.object({
|
||||
title: z.string().min(8).max(22).default("Market Opportunity").meta({
|
||||
description: "Main heading shown at the top-left.",
|
||||
}),
|
||||
subtitle: z.string().min(40).max(110).default(
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt."
|
||||
).meta({
|
||||
description: "Supporting text under the main heading.",
|
||||
}),
|
||||
bullets: z
|
||||
.array(BulletSchema)
|
||||
.min(4)
|
||||
.max(4)
|
||||
.default([
|
||||
{ text: "Ut enim ad minim veniam, quis" },
|
||||
{ text: "Ut enim ad minim veniam, quis" },
|
||||
{ text: "Ut enim ad minim veniam, quis" },
|
||||
{ text: "Ut enim ad minim veniam, quis" },
|
||||
])
|
||||
.meta({
|
||||
description: "Four bullet-line entries shown on the left.",
|
||||
}),
|
||||
values: z
|
||||
.array(z.string().min(2).max(6))
|
||||
.min(4)
|
||||
.max(4)
|
||||
.default(["$33", "$20", "$120", "$200"])
|
||||
.meta({
|
||||
description: "Four values shown from outer to inner circles.",
|
||||
}),
|
||||
});
|
||||
|
||||
export type SchemaType = z.infer<typeof Schema>;
|
||||
|
||||
|
||||
const COLORS = [
|
||||
"var(--graph-0,#5f7f79)",
|
||||
"var(--graph-1,#1f5a4f)",
|
||||
"var(--graph-2,#0d4f43)",
|
||||
"var(--graph-3,#06463d)",
|
||||
];
|
||||
|
||||
const MarketOpportunitySlide = ({ data }: { data: Partial<SchemaType> }) => {
|
||||
const { title, subtitle, bullets, values } = data;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative h-[720px] w-[1280px] overflow-hidden rounded-[24px]"
|
||||
style={{
|
||||
backgroundColor: "var(--background-color,#DAE1DE)",
|
||||
fontFamily: "var(--body-font-family,'Bricolage Grotesque')",
|
||||
}}
|
||||
>
|
||||
<div className="px-[56px] pt-[72px]">
|
||||
<h2
|
||||
className="text-[80px] font-semibold leading-[108.4%] tracking-[-2.419px] text-[#15342D]"
|
||||
style={{ color: "var(--primary-color,#15342D)" }}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<p
|
||||
className="mt-[20px] w-[730px] text-[24px] font-normal text-[#15342DCC]"
|
||||
style={{ color: "var(--background-text,#15342DCC)" }}
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="absolute left-[56px] top-[368px] space-y-[42px]">
|
||||
{bullets?.map((bullet, index) => (
|
||||
<div key={index} className="relative flex items-center">
|
||||
<span
|
||||
className="mr-[14px] h-[14px] w-[14px] rounded-full bg-[#0a4a3f]"
|
||||
style={{ backgroundColor: "var(--graph-0,#0a4a3f)" }}
|
||||
/>
|
||||
<p
|
||||
className="w-[640px] text-[24px] font-normal text-[#15342DCC]"
|
||||
style={{ color: "var(--background-text,#15342DCC)" }}
|
||||
>
|
||||
{bullet.text}
|
||||
</p>
|
||||
<span
|
||||
className="ml-[8px] h-[2px] w-[80px] bg-[#8ea8a5]"
|
||||
style={{ backgroundColor: "var(--stroke,#8ea8a5)" }}
|
||||
/>
|
||||
<span
|
||||
className="h-[6px] w-[6px] rounded-full bg-[#edf2f1]"
|
||||
style={{ backgroundColor: "var(--primary-text,#edf2f1)" }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-[58px] right-[48px] h-[474px] w-[474px]">
|
||||
{values?.map((value, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
width: 237 + (index * 50),
|
||||
height: 237 + (index * 50),
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
backgroundColor: COLORS[index],
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="pt-[24px] text-center text-[24px] font-normal text-white"
|
||||
style={{ color: "var(--primary-text,#ffffff)" }}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarketOpportunitySlide;
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
"use client";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { setTelemetryEnabled } from "@/utils/mixpanel";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
const PrivacySettings = () => {
|
||||
const [trackingEnabled, setTrackingEnabled] = useState<boolean | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
if (window.electron?.telemetryStatus) {
|
||||
const data = await window.electron.telemetryStatus();
|
||||
setTrackingEnabled(data.telemetryEnabled);
|
||||
} else {
|
||||
const res = await fetch("/api/telemetry-status");
|
||||
const data = await res.json();
|
||||
setTrackingEnabled(data.telemetryEnabled);
|
||||
}
|
||||
} catch {
|
||||
setTrackingEnabled(true);
|
||||
}
|
||||
}
|
||||
fetchStatus();
|
||||
}, []);
|
||||
|
||||
const handleTrackingToggle = async (enabled: boolean) => {
|
||||
const prev = trackingEnabled;
|
||||
setTrackingEnabled(enabled);
|
||||
setTelemetryEnabled(enabled);
|
||||
setSaving(true);
|
||||
try {
|
||||
if (window.electron?.setUserConfig) {
|
||||
await window.electron.setUserConfig({
|
||||
DISABLE_ANONYMOUS_TRACKING: enabled ? undefined : "true",
|
||||
} as any);
|
||||
} else {
|
||||
await fetch("/api/user-config", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
DISABLE_ANONYMOUS_TRACKING: enabled ? undefined : "true",
|
||||
}),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setTrackingEnabled(prev);
|
||||
setTelemetryEnabled(prev ?? true);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (trackingEnabled === null) {
|
||||
return (
|
||||
<div className="w-full bg-[#F9F8F8] p-7 rounded-[20px] flex items-center justify-center min-h-[200px]">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-[#5146E5]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-6">
|
||||
<div className="bg-[#F9F8F8] p-7 rounded-[20px]">
|
||||
<h4 className="text-sm font-semibold text-[#191919] mb-1">
|
||||
Usage analytics
|
||||
</h4>
|
||||
<p className="text-xs text-[#6B7280] mb-6 leading-relaxed max-w-lg">
|
||||
Share anonymous usage data to help us improve Presenton. No personal information or presentation content is collected.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 rounded-[10px] bg-white border border-[#EDEEEF] p-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="tracking-toggle"
|
||||
className="text-sm font-medium text-[#191919] cursor-pointer select-none block"
|
||||
>
|
||||
Share anonymous usage data
|
||||
</label>
|
||||
<p className="text-xs text-[#9CA3AF] mt-0.5">
|
||||
{trackingEnabled
|
||||
? "Anonymous usage data is being shared."
|
||||
: "Anonymous usage data is not being shared"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{saving && (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-[#9CA3AF]" />
|
||||
)}
|
||||
<Switch
|
||||
id="tracking-toggle"
|
||||
checked={trackingEnabled}
|
||||
onCheckedChange={handleTrackingToggle}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrivacySettings;
|
||||
|
|
@ -0,0 +1,407 @@
|
|||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Check,
|
||||
ChevronUp,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Crown,
|
||||
User,
|
||||
UserCheck,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { getApiUrl } from "@/utils/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface CodexConfigProps {
|
||||
codexModel: string;
|
||||
onInputChange: (value: string | boolean, field: string) => void;
|
||||
}
|
||||
|
||||
type AuthStatus = "checking" | "unauthenticated" | "polling" | "authenticated";
|
||||
|
||||
interface StatusResponse {
|
||||
status: string;
|
||||
account_id?: string;
|
||||
username?: string;
|
||||
email?: string;
|
||||
is_pro?: boolean;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
interface CodexModel {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const CHATGPT_MODELS: CodexModel[] = [
|
||||
{ id: "gpt-5.1", name: "GPT-5.1" },
|
||||
{ id: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max" },
|
||||
{ id: "gpt-5.2", name: "GPT-5.2" },
|
||||
{ id: "gpt-5.2-codex", name: "GPT-5.2 Codex" },
|
||||
{ id: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
|
||||
{ id: "gpt-5.4-mini", name: "GPT-5.4 Mini" },
|
||||
{ id: "gpt-5.4", name: "GPT-5.4" },
|
||||
];
|
||||
|
||||
const DEFAULT_CODEX_MODEL = "gpt-5.4-mini";
|
||||
|
||||
export default function CodexConfig({
|
||||
codexModel,
|
||||
onInputChange,
|
||||
}: CodexConfigProps) {
|
||||
const [authStatus, setAuthStatus] = useState<AuthStatus>("checking");
|
||||
const [accountId, setAccountId] = useState<string | null>(null);
|
||||
const [username, setUsername] = useState<string | null>(null);
|
||||
const [email, setEmail] = useState<string | null>(null);
|
||||
const [isPro, setIsPro] = useState<boolean | null>(null);
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [manualCode, setManualCode] = useState("");
|
||||
const [isExchanging, setIsExchanging] = useState(false);
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [openModelSelect, setOpenModelSelect] = useState(false);
|
||||
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
checkCurrentAuthStatus();
|
||||
return () => stopPolling();
|
||||
}, []);
|
||||
|
||||
const applyProfile = (data: Partial<StatusResponse>) => {
|
||||
setAccountId(data.account_id ?? null);
|
||||
setUsername(data.username ?? null);
|
||||
setEmail(data.email ?? null);
|
||||
setIsPro(typeof data.is_pro === "boolean" ? data.is_pro : null);
|
||||
};
|
||||
|
||||
const checkCurrentAuthStatus = async () => {
|
||||
try {
|
||||
const res = await fetch(getApiUrl("/api/v1/ppt/codex/auth/status"));
|
||||
if (!res.ok) {
|
||||
setAuthStatus("unauthenticated");
|
||||
applyProfile({});
|
||||
return;
|
||||
}
|
||||
const data: StatusResponse = await res.json();
|
||||
if (data.status === "authenticated") {
|
||||
setAuthStatus("authenticated");
|
||||
applyProfile(data);
|
||||
} else {
|
||||
setAuthStatus("unauthenticated");
|
||||
applyProfile({});
|
||||
}
|
||||
} catch {
|
||||
setAuthStatus("unauthenticated");
|
||||
applyProfile({});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignIn = async () => {
|
||||
try {
|
||||
const res = await fetch(getApiUrl("/api/v1/ppt/codex/auth/initiate"), {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to initiate auth");
|
||||
const data = await res.json();
|
||||
const { session_id, url } = data;
|
||||
|
||||
setSessionId(session_id);
|
||||
setAuthStatus("polling");
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
|
||||
pollIntervalRef.current = setInterval(async () => {
|
||||
try {
|
||||
const pollRes = await fetch(
|
||||
getApiUrl(`/api/v1/ppt/codex/auth/status/${session_id}`)
|
||||
);
|
||||
if (!pollRes.ok) return;
|
||||
const pollData: StatusResponse = await pollRes.json();
|
||||
|
||||
if (pollData.status === "success") {
|
||||
stopPolling();
|
||||
setAuthStatus("authenticated");
|
||||
applyProfile(pollData);
|
||||
setSessionId(null);
|
||||
if (!codexModel) {
|
||||
onInputChange(DEFAULT_CODEX_MODEL, "codex_model");
|
||||
}
|
||||
toast.success("Signed in to ChatGPT successfully");
|
||||
} else if (pollData.status === "failed") {
|
||||
stopPolling();
|
||||
setAuthStatus("unauthenticated");
|
||||
applyProfile({});
|
||||
toast.error("Authentication failed. Please try again.");
|
||||
}
|
||||
} catch {
|
||||
// keep polling on transient errors
|
||||
}
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
toast.error("Failed to start sign-in flow");
|
||||
setAuthStatus("unauthenticated");
|
||||
applyProfile({});
|
||||
}
|
||||
};
|
||||
|
||||
const handleManualExchange = async () => {
|
||||
if (!sessionId || !manualCode.trim()) return;
|
||||
setIsExchanging(true);
|
||||
try {
|
||||
const res = await fetch(getApiUrl("/api/v1/ppt/codex/auth/exchange"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session_id: sessionId, code: manualCode.trim() }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail || "Exchange failed");
|
||||
}
|
||||
const data = await res.json();
|
||||
stopPolling();
|
||||
setAuthStatus("authenticated");
|
||||
applyProfile(data);
|
||||
setSessionId(null);
|
||||
setManualCode("");
|
||||
if (!codexModel) {
|
||||
onInputChange(DEFAULT_CODEX_MODEL, "codex_model");
|
||||
}
|
||||
toast.success("Signed in to ChatGPT successfully");
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || "Code exchange failed");
|
||||
} finally {
|
||||
setIsExchanging(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelPolling = () => {
|
||||
stopPolling();
|
||||
setSessionId(null);
|
||||
setManualCode("");
|
||||
setAuthStatus("unauthenticated");
|
||||
};
|
||||
|
||||
const handleSignOut = async () => {
|
||||
setIsLoggingOut(true);
|
||||
try {
|
||||
await fetch(getApiUrl("/api/v1/ppt/codex/auth/logout"), { method: "POST" });
|
||||
setAuthStatus("unauthenticated");
|
||||
applyProfile({});
|
||||
onInputChange("codex", "LLM");
|
||||
onInputChange('', "codex_model");
|
||||
toast.success("Signed out from ChatGPT");
|
||||
} catch {
|
||||
toast.error("Sign out failed");
|
||||
} finally {
|
||||
setIsLoggingOut(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshToken = async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
const res = await fetch(getApiUrl("/api/v1/ppt/codex/auth/refresh"), {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) throw new Error("Refresh failed");
|
||||
const data = await res.json();
|
||||
applyProfile(data);
|
||||
toast.success("Token refreshed successfully");
|
||||
} catch {
|
||||
toast.error("Token refresh failed. Please sign in again.");
|
||||
setAuthStatus("unauthenticated");
|
||||
applyProfile({});
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (authStatus === "checking") {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-3 text-gray-400">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span className="text-xs">Checking status…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (authStatus === "polling") {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<Loader2 className="w-4 h-4 text-gray-500 animate-spin" />
|
||||
<span className="text-sm text-gray-600">Waiting for sign-in…</span>
|
||||
<button
|
||||
onClick={handleCancelPolling}
|
||||
className="text-xs text-gray-400 hover:text-gray-600 underline underline-offset-2 ml-auto"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-gray-400">
|
||||
Paste redirect URL or code if not redirected automatically
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Paste URL or code…"
|
||||
className="flex-1 px-2 py-2 outline-none border border-gray-300 rounded-lg text-xs focus:border-gray-400 transition-colors"
|
||||
value={manualCode}
|
||||
onChange={(e) => setManualCode(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
onClick={handleManualExchange}
|
||||
disabled={isExchanging || !manualCode.trim()}
|
||||
className="px-3 py-2 bg-[#EDEEEF] hover:bg-[#E4E5E6] disabled:opacity-40 rounded-lg text-xs font-medium text-[#101323] transition-colors"
|
||||
>
|
||||
{isExchanging ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
"Submit"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (authStatus === "authenticated") {
|
||||
const planLabel = isPro === true ? "Pro" : isPro === false ? "Free" : "Unknown";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 p-3 border border-[#EDEEEF] rounded-lg">
|
||||
<UserCheck className="w-5 h-5 text-black shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-800 truncate">
|
||||
{username || email || (accountId ? `Account ${accountId}` : "ChatGPT Account")}
|
||||
</p>
|
||||
|
||||
</div>
|
||||
{email && username && (
|
||||
<p className="text-xs text-gray-500 truncate">{email}</p>
|
||||
)}
|
||||
{!email && accountId && (
|
||||
<p className="text-xs text-gray-500 truncate">ID: {accountId}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-400">Signed in to ChatGPT</p>
|
||||
</div>
|
||||
<div className="flex gap-1.5 shrink-0">
|
||||
<button
|
||||
onClick={handleRefreshToken}
|
||||
disabled={isRefreshing}
|
||||
title="Refresh token"
|
||||
className="w-8 h-8 flex items-center justify-center rounded-full bg-[#EDEEEF] hover:bg-[#E4E5E6] disabled:opacity-40 transition-colors"
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-gray-500" />
|
||||
) : (
|
||||
<RefreshCw className="w-3.5 h-3.5 text-gray-500" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
disabled={isLoggingOut}
|
||||
title="Sign out"
|
||||
className="w-8 h-8 flex items-center justify-center rounded-full bg-[#EDEEEF] hover:bg-[#E4E5E6] disabled:opacity-40 transition-colors"
|
||||
>
|
||||
{isLoggingOut ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-gray-500" />
|
||||
) : (
|
||||
<Trash2 className="w-3.5 h-3.5 text-gray-500" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Select GPT Model
|
||||
</label>
|
||||
<Popover open={openModelSelect} onOpenChange={setOpenModelSelect}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openModelSelect}
|
||||
className="w-full h-10 px-3 outline-none border border-gray-300 rounded-lg hover:border-gray-400 justify-between"
|
||||
>
|
||||
<span className="text-sm text-gray-900">
|
||||
{codexModel
|
||||
? (CHATGPT_MODELS.find((m) => m.id === codexModel)?.name ?? codexModel)
|
||||
: "Select a model"}
|
||||
</span>
|
||||
<ChevronUp className="w-4 h-4 text-gray-400" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
align="start"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search models…" />
|
||||
<CommandList>
|
||||
<CommandEmpty>No model found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{CHATGPT_MODELS.map((model) => (
|
||||
<CommandItem
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
onSelect={(value) => {
|
||||
onInputChange(value, "codex_model");
|
||||
setOpenModelSelect(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
codexModel === model.id ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm text-gray-900">
|
||||
{model.name}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleSignIn}
|
||||
className="mt-8 py-2.5 px-3.5 bg-[#EDEEEF] hover:bg-[#E4E5E6] rounded-[48px] text-xs font-semibold text-[#101323] transition-colors"
|
||||
>
|
||||
Sign in with ChatGPT
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,10 +6,12 @@ import { Switch } from '@/components/ui/switch';
|
|||
import { cn } from '@/lib/utils';
|
||||
import { LLMConfig } from '@/types/llm_config';
|
||||
import { LLM_PROVIDERS } from '@/utils/providerConstants';
|
||||
import { Check, Loader2, Eye, EyeOff, ChevronUp, User, RefreshCw, LogOut } from 'lucide-react';
|
||||
import { Check, Loader2, Eye, EyeOff, ChevronUp } from 'lucide-react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { notify } from '@/components/ui/sonner';
|
||||
import { toast } from 'sonner';
|
||||
import { getApiUrl } from '@/utils/api';
|
||||
import CodexConfig from '@/components/CodexConfig';
|
||||
|
||||
|
||||
interface OpenAIConfigProps {
|
||||
|
|
@ -46,6 +48,8 @@ const TextProvider = ({
|
|||
return 'OLLAMA_MODEL';
|
||||
case 'custom':
|
||||
return 'CUSTOM_MODEL';
|
||||
case 'codex':
|
||||
return 'CODEX_MODEL';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
|
|
@ -119,7 +123,7 @@ const TextProvider = ({
|
|||
try {
|
||||
let response: Response;
|
||||
if (selectedProvider === 'google') {
|
||||
response = await fetch('/api/v1/ppt/google/models/available', {
|
||||
response = await fetch(getApiUrl('/api/v1/ppt/google/models/available'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -129,7 +133,7 @@ const TextProvider = ({
|
|||
}),
|
||||
});
|
||||
} else if (selectedProvider === 'anthropic') {
|
||||
response = await fetch('/api/v1/ppt/anthropic/models/available', {
|
||||
response = await fetch(getApiUrl('/api/v1/ppt/anthropic/models/available'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -139,9 +143,9 @@ const TextProvider = ({
|
|||
}),
|
||||
});
|
||||
} else if (selectedProvider === 'ollama') {
|
||||
response = await fetch('/api/v1/ppt/ollama/models/supported');
|
||||
response = await fetch(getApiUrl('/api/v1/ppt/ollama/models/supported'));
|
||||
} else {
|
||||
response = await fetch('/api/v1/ppt/openai/models/available', {
|
||||
response = await fetch(getApiUrl('/api/v1/ppt/openai/models/available'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -228,33 +232,6 @@ const TextProvider = ({
|
|||
</p>
|
||||
</div>
|
||||
<div>
|
||||
{selectedProvider === 'codex' && false && <div className='border border-[#EDEEEF] mb-4 rounded-[8px] p-5 flex justify-between items-center'>
|
||||
<div className='flex items-center gap-2.5'>
|
||||
<User className='w-4 h-4 text-gray-500' />
|
||||
<div>
|
||||
<h4 className='text-[#19001F] text-sm font-medium'>Acc: 123-455-acghk</h4>
|
||||
<p className='text-xs text-[#B3B3B3]'>Signed in to ChatGPT</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div className='flex items-center gap-2.5'>
|
||||
<ToolTip content='Refresh ChatGPT account'>
|
||||
|
||||
|
||||
<button className='px-3.5 py-2.5 rounded-full bg-[#EDEEEF]'>
|
||||
|
||||
<RefreshCw className='w-4 h-4 text-black' />
|
||||
</button>
|
||||
</ToolTip>
|
||||
<ToolTip content='Logout from ChatGPT'>
|
||||
<button className='px-3.5 py-2.5 rounded-full bg-[#EDEEEF]'>
|
||||
|
||||
<LogOut className='w-4 h-4 text-black' />
|
||||
</button>
|
||||
</ToolTip>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
<div className={`flex gap-4 justify-end ${selectedProvider === 'codex' ? 'items-end' : 'items-start'}`}>
|
||||
<div className="relative w-[205px] ">
|
||||
<div className="flex flex-col justify-start ">
|
||||
|
|
@ -380,11 +357,17 @@ const TextProvider = ({
|
|||
</>
|
||||
)}
|
||||
</>
|
||||
) : selectedProvider === 'codex' ?
|
||||
<>
|
||||
<button className='px-3.5 py-2.5 bg-[#EDEEEF] mt-auto rounded-[58px] w-full text-xs font-medium text-[#101323]'>Sign in with ChatGPT</button>
|
||||
</>
|
||||
: (
|
||||
) : selectedProvider === 'codex' ? (
|
||||
<div className="w-full mt-0 rounded-[12px]">
|
||||
<CodexConfig
|
||||
codexModel={llmConfig.CODEX_MODEL || ''}
|
||||
onInputChange={(value, field) => {
|
||||
const normalizedField = field === 'codex_model' ? 'CODEX_MODEL' : field;
|
||||
onInputChange(value, normalizedField);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<label className="block text-sm font-medium capitalize text-gray-700 mb-2">
|
||||
{selectedProvider === 'custom' ? 'Custom LLM API Key' : `${llmConfig.LLM} API Key`}
|
||||
|
|
@ -452,7 +435,7 @@ const TextProvider = ({
|
|||
|
||||
|
||||
{/* Model Selection - only show if models are available */}
|
||||
{modelsChecked && availableModels.length > 0 ? (
|
||||
{selectedProvider !== 'codex' && modelsChecked && availableModels.length > 0 ? (
|
||||
<div className="w-[205px]">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
|
|
@ -532,7 +515,7 @@ const TextProvider = ({
|
|||
</div>
|
||||
</div>
|
||||
{/* Show message if no models found */}
|
||||
{modelsChecked && availableModels.length === 0 && (
|
||||
{selectedProvider !== 'codex' && modelsChecked && availableModels.length === 0 && (
|
||||
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<p className="text-sm text-yellow-800">
|
||||
No models found. Please make sure your provider credentials are valid and the selected provider is reachable.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,136 @@
|
|||
'use client'
|
||||
import React, { useEffect, useState, memo, useCallback } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { addNewSlide } from "@/store/slices/presentationGeneration";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { getCustomTemplateDetails } from "@/app/hooks/useCustomTemplates";
|
||||
import { getTemplatesByTemplateName } from "@/app/presentation-templates";
|
||||
|
||||
interface LayoutItemProps {
|
||||
layout: any;
|
||||
onSelect: (sampleData: any, layoutId: string) => void;
|
||||
}
|
||||
|
||||
const LayoutItem = memo(({ layout, onSelect }: LayoutItemProps) => {
|
||||
const { component: LayoutComponent, sampleData, layoutId } = layout;
|
||||
return (
|
||||
<div
|
||||
onClick={() => onSelect(sampleData, layoutId)}
|
||||
className="relative cursor-pointer overflow-hidden aspect-video"
|
||||
>
|
||||
<div className="absolute cursor-pointer 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%]">
|
||||
<LayoutComponent data={sampleData} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
LayoutItem.displayName = 'LayoutItem';
|
||||
interface NewSlideV1Props {
|
||||
setShowNewSlideSelection: (show: boolean) => void;
|
||||
templateID: string;
|
||||
index: number;
|
||||
presentationId: string;
|
||||
}
|
||||
const NewSlideV1 = ({
|
||||
setShowNewSlideSelection,
|
||||
templateID,
|
||||
index,
|
||||
presentationId,
|
||||
}: NewSlideV1Props) => {
|
||||
const dispatch = useDispatch();
|
||||
const [layouts, setLayouts] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const isCustomTemplate = templateID.startsWith("custom-");
|
||||
const handleNewSlide = useCallback((sampleData: any, id: string) => {
|
||||
try {
|
||||
const newSlide = {
|
||||
id: uuidv4(),
|
||||
index: index,
|
||||
content: sampleData,
|
||||
layout_group: templateID,
|
||||
layout: isCustomTemplate ? `${templateID}:${id}` : id,
|
||||
presentation: presentationId,
|
||||
};
|
||||
dispatch(addNewSlide({ slideData: newSlide, index }));
|
||||
setShowNewSlideSelection(false);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error("Error adding new slide");
|
||||
}
|
||||
}, [index, templateID, presentationId, dispatch, setShowNewSlideSelection]);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (layouts.length > 0 || loading) return;
|
||||
|
||||
const fetchLayouts = async () => {
|
||||
|
||||
if (isCustomTemplate) {
|
||||
setLoading(true);
|
||||
const customTemplateId = templateID.split("custom-")[1];
|
||||
const templateDetails = await getCustomTemplateDetails(customTemplateId, "Custom Template", "User-created template");
|
||||
setLayouts(templateDetails?.layouts || []);
|
||||
setLoading(false);
|
||||
} else {
|
||||
setLoading(true);
|
||||
const templateDetails = getTemplatesByTemplateName(templateID);
|
||||
setLayouts(templateDetails || []);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchLayouts();
|
||||
|
||||
}, []);
|
||||
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="my-6 w-full bg-gray-50 p-8 max-w-[1280px]">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h2 className="text-2xl font-semibold">Select a Slide Layout</h2>
|
||||
<Trash2
|
||||
onClick={() => setShowNewSlideSelection(false)}
|
||||
className="text-gray-500 text-2xl cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-gray-500" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-6 w-full bg-gray-50 p-8 max-w-[1280px]">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h2 className="text-2xl font-semibold">Select a Slide Layout</h2>
|
||||
<Trash2
|
||||
onClick={() => setShowNewSlideSelection(false)}
|
||||
className="text-gray-500 text-2xl cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{layouts.map((layout: any) => (
|
||||
<LayoutItem
|
||||
key={layout.layoutId}
|
||||
layout={layout}
|
||||
onSelect={handleNewSlide}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewSlideV1;
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,248 @@
|
|||
"use client";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
X,
|
||||
Minimize2,
|
||||
Maximize2,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Slide } from "../types/slide";
|
||||
import { V1ContentRender } from "./V1ContentRender";
|
||||
|
||||
|
||||
interface PresentationModeProps {
|
||||
slides: Slide[];
|
||||
currentSlide: number;
|
||||
|
||||
isFullscreen: boolean;
|
||||
onFullscreenToggle: () => void;
|
||||
onExit: () => void;
|
||||
onSlideChange: (slideNumber: number) => void;
|
||||
}
|
||||
|
||||
const PresentationMode: React.FC<PresentationModeProps> = ({
|
||||
|
||||
slides,
|
||||
currentSlide,
|
||||
|
||||
isFullscreen,
|
||||
onFullscreenToggle,
|
||||
onExit,
|
||||
onSlideChange,
|
||||
|
||||
|
||||
}) => {
|
||||
if (slides === undefined || slides === null || slides.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
const recomputeScale = useCallback(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const padding = isFullscreen ? 0 : 64; // match p-8 when not fullscreen
|
||||
const fullscreenMargin = isFullscreen ? 16 : 0; // small safety margin to prevent clipping
|
||||
const availableWidth = Math.max(window.innerWidth - padding - fullscreenMargin, 0);
|
||||
const availableHeight = Math.max(window.innerHeight - padding - fullscreenMargin, 0);
|
||||
const baseW = 1280;
|
||||
const baseH = 720;
|
||||
const s = Math.min(availableWidth / baseW, availableHeight / baseH);
|
||||
|
||||
}, [isFullscreen]);
|
||||
|
||||
useEffect(() => {
|
||||
recomputeScale();
|
||||
window.addEventListener("resize", recomputeScale);
|
||||
return () => window.removeEventListener("resize", recomputeScale);
|
||||
}, [recomputeScale]);
|
||||
|
||||
|
||||
// Modify the handleKeyPress to prevent default behavior
|
||||
const handleKeyPress = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
event.preventDefault(); // Prevent default scroll behavior
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowRight":
|
||||
case "ArrowDown":
|
||||
case " ": // Space key
|
||||
if (currentSlide < slides.length - 1) {
|
||||
onSlideChange(currentSlide + 1);
|
||||
}
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
case "ArrowUp":
|
||||
if (currentSlide > 0) {
|
||||
onSlideChange(currentSlide - 1);
|
||||
}
|
||||
break;
|
||||
case "Escape":
|
||||
// If fullscreen is active, only exit fullscreen on first ESC. Second ESC exits present mode.
|
||||
if (document.fullscreenElement) {
|
||||
try { document.exitFullscreen(); } catch (_) { }
|
||||
return;
|
||||
}
|
||||
onExit();
|
||||
break;
|
||||
case "f":
|
||||
case "F":
|
||||
onFullscreenToggle();
|
||||
break;
|
||||
}
|
||||
},
|
||||
[currentSlide, slides.length, onSlideChange, onExit, onFullscreenToggle, isFullscreen]
|
||||
);
|
||||
|
||||
// Add both keydown and keyup listeners
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Prevent default behavior for arrow keys and space
|
||||
if (
|
||||
["ArrowRight", "ArrowLeft", "ArrowUp", "ArrowDown", " "].includes(e.key)
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
handleKeyPress(e);
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [handleKeyPress]);
|
||||
|
||||
// Add click handlers for the slide area
|
||||
const handleSlideClick = (e: React.MouseEvent) => {
|
||||
// Don't trigger navigation if clicking on controls
|
||||
if ((e.target as HTMLElement).closest(".presentation-controls")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clickX = e.clientX;
|
||||
const windowWidth = window.innerWidth;
|
||||
|
||||
if (clickX < windowWidth / 3) {
|
||||
if (currentSlide > 0) {
|
||||
onSlideChange(currentSlide - 1);
|
||||
}
|
||||
} else if (clickX > (windowWidth * 2) / 3) {
|
||||
if (currentSlide < slides.length - 1) {
|
||||
onSlideChange(currentSlide + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle Escape key separately
|
||||
useEffect(() => {
|
||||
const handleEscKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && isFullscreen) {
|
||||
onFullscreenToggle(); // Just toggle fullscreen, don't exit presentation
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleEscKey);
|
||||
return () => document.removeEventListener("keydown", handleEscKey);
|
||||
}, [isFullscreen, onFullscreenToggle]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex flex-col"
|
||||
style={{ backgroundColor: "var(--page-background-color,#c8c7c9)" }}
|
||||
tabIndex={0}
|
||||
onClick={handleSlideClick}
|
||||
>
|
||||
{/* Controls - Only show when not in fullscreen */}
|
||||
{!isFullscreen && (
|
||||
<>
|
||||
<div className="presentation-controls absolute top-4 right-4 flex items-center gap-2 z-50">
|
||||
<Button
|
||||
variant="ghost"
|
||||
style={{ color: "var(--text-body-color,#000000)" }}
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onFullscreenToggle();
|
||||
}}
|
||||
className="text-white hover:bg-white/20"
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<Minimize2 className="h-5 w-5" />
|
||||
) : (
|
||||
<Maximize2 className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
style={{ color: "var(--text-body-color,#000000)" }}
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onExit();
|
||||
}}
|
||||
className="text-white hover:bg-white/20"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="presentation-controls absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-4 z-50">
|
||||
<Button
|
||||
variant="ghost"
|
||||
style={{ color: "var(--text-body-color,#000000)" }}
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSlideChange(currentSlide - 1);
|
||||
}}
|
||||
disabled={currentSlide === 0}
|
||||
className="text-white hover:bg-white/20"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" style={{ color: "var(--text-body-color,#000000)" }} />
|
||||
</Button>
|
||||
<span className="text-white"
|
||||
style={{ color: "var(--text-body-color,#000000)" }}
|
||||
>
|
||||
{currentSlide + 1} / {slides.length}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
style={{ color: "var(--text-body-color,#000000)" }}
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSlideChange(currentSlide + 1);
|
||||
}}
|
||||
disabled={currentSlide === slides.length - 1}
|
||||
className="text-white hover:bg-white/20"
|
||||
>
|
||||
<ChevronRight className="h-5 w-5" style={{ color: "var(--text-body-color,#000000)" }} />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Slides (all mounted, only current visible) */}
|
||||
<div className={`flex-1 flex items-center justify-center ${isFullscreen ? "p-0" : "p-8"}`}>
|
||||
<div className="w-full h-full flex items-center justify-center relative" >
|
||||
<div
|
||||
className={` rounded-sm font-inter relative w-full h-full flex items-center justify-center`}
|
||||
|
||||
>
|
||||
{slides.length > 0 && slides.map((slide, index) => (
|
||||
<div
|
||||
key={slide.id}
|
||||
className={index === currentSlide ? " w-full h-full flex items-center justify-center" : "hidden w-full h-full"}
|
||||
>
|
||||
<V1ContentRender slide={slide} isEditMode={true} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PresentationMode;
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
"use client";
|
||||
import React, { memo, useMemo } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { TemplateWithData } from "@/app/presentation-templates/utils";
|
||||
import { CompiledLayout } from "@/app/hooks/compileLayout";
|
||||
|
||||
|
||||
|
||||
|
||||
export function TemplatePreviewStage({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="relative overflow-hidden px-5 pb-5 pt-5 h-[230px]">
|
||||
<img
|
||||
src="/card_bg.svg"
|
||||
alt=""
|
||||
className="absolute top-0 left-0 w-full h-full object-cover"
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const LayoutsBadge = memo(function LayoutsBadge({ count }: { count: number }) {
|
||||
return (
|
||||
<span className="text-xs font-syne absolute top-3.5 left-4 z-40 inline-flex items-center rounded-full bg-[#333333] px-3 py-1 font-semibold text-white">
|
||||
Layouts-{count}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
export const ScaledSlidePreview = memo(function ScaledSlidePreview({
|
||||
children,
|
||||
id,
|
||||
index,
|
||||
isOutline = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
id: string;
|
||||
index: number;
|
||||
isOutline?: boolean;
|
||||
}) {
|
||||
const PREVIEW_SCALE = isOutline ? 0.2 : 0.24;
|
||||
const SLIDE_HEIGHT = 720 * PREVIEW_SCALE;
|
||||
const SLIDE_WIDTH = 1280;
|
||||
const SLIDE_NATIVE_HEIGHT = 720;
|
||||
return (
|
||||
<div
|
||||
key={`${id}-preview-${index}`}
|
||||
className="relative"
|
||||
style={{ height: `${SLIDE_HEIGHT}px`, overflow: "hidden" }}
|
||||
>
|
||||
<div
|
||||
className={`absolute top-0 ${isOutline ? "left-0" : "left-8"} pointer-events-none`}
|
||||
style={{
|
||||
width: SLIDE_WIDTH,
|
||||
height: SLIDE_NATIVE_HEIGHT,
|
||||
transformOrigin: "top left",
|
||||
transform: `scale(${PREVIEW_SCALE})`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const InbuiltTemplatePreview = memo(function InbuiltTemplatePreview({
|
||||
layouts,
|
||||
templateId,
|
||||
isOutline = false,
|
||||
}: {
|
||||
layouts: TemplateWithData[];
|
||||
templateId: string;
|
||||
isOutline?: boolean;
|
||||
}) {
|
||||
const previewLayouts = useMemo(() => layouts.slice(0, 2), [layouts]);
|
||||
return (
|
||||
<div className="relative z-10 flex flex-col gap-3 overflow-hidden">
|
||||
{previewLayouts.map((layout, index) => {
|
||||
const LayoutComponent = layout.component;
|
||||
return (
|
||||
<ScaledSlidePreview key={`${templateId}-preview-${index}`} id={templateId} index={index} isOutline={isOutline}>
|
||||
<LayoutComponent data={layout.sampleData} />
|
||||
</ScaledSlidePreview>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const CustomTemplatePreview = memo(function CustomTemplatePreview({
|
||||
previewLayouts,
|
||||
loading,
|
||||
templateId,
|
||||
isOutline = false,
|
||||
}: {
|
||||
previewLayouts: CompiledLayout[];
|
||||
loading: boolean;
|
||||
templateId: string;
|
||||
isOutline?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative z-10 flex flex-col gap-3">
|
||||
{loading ? (
|
||||
[...Array(2)].map((_, index) => (
|
||||
<div
|
||||
key={`${templateId}-loading-${index}`}
|
||||
className="relative w-full aspect-video flex items-center justify-center"
|
||||
>
|
||||
<Loader2 className="h-4 w-4 animate-spin text-slate-300" />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
previewLayouts.slice(0, 2).map((layout, index) => {
|
||||
const LayoutComponent = layout.component;
|
||||
return (
|
||||
<ScaledSlidePreview key={`${templateId}-preview-${index}`} id={templateId} index={index} isOutline={isOutline}>
|
||||
<LayoutComponent data={layout.sampleData} />
|
||||
</ScaledSlidePreview>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,276 @@
|
|||
"use client";
|
||||
|
||||
|
||||
|
||||
import React, { useEffect, useCallback, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
|
||||
|
||||
import { useFileUpload } from "./hooks/useFileUpload";
|
||||
import { useTemplateCreation } from "./hooks/useTemplateCreation";
|
||||
import { useLayoutSaving } from "./hooks/useLayoutSaving";
|
||||
|
||||
import { ProcessedSlide } from "./types";
|
||||
import { TAILWIND_CDN_URL } from "./constants";
|
||||
import { TemplateStudioHeader } from "./components/TemplateStudioHeader";
|
||||
import { TemplateCreationProgress } from "./components/TemplateCreationProgress";
|
||||
import { Step2FontManagement } from "./components/steps/Step2FontManagement";
|
||||
import { Step3SlidePreview } from "./components/steps/Step3SlidePreview";
|
||||
import { Step4TemplateCreation } from "./components/steps/Step4TemplateCreation";
|
||||
import { SaveLayoutButton } from "./components/SaveLayoutButton";
|
||||
import { SaveLayoutModal } from "./components/SaveLayoutModal";
|
||||
import { FileUploadSection } from "./components/FileUploadSection";
|
||||
|
||||
import { useFontLoader } from "../hooks/useFontLoad";
|
||||
import Header from "@/app/(presentation-generator)/(dashboard)/dashboard/components/Header";
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const CustomTemplatePage = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const [schemaEditorSlideIndex, setSchemaEditorSlideIndex] = useState<number | null>(null);
|
||||
const [schemaPreviewData, setSchemaPreviewData] = useState<Record<number, Record<string, any>>>({});
|
||||
|
||||
const { selectedFile, handleFileSelect, removeFile } = useFileUpload();
|
||||
|
||||
|
||||
const {
|
||||
state,
|
||||
uploadedFonts,
|
||||
slides,
|
||||
setSlides,
|
||||
completedSlides,
|
||||
checkFonts,
|
||||
uploadFont,
|
||||
removeFont,
|
||||
fontUploadAndPreview,
|
||||
initTemplateCreation,
|
||||
retrySlide,
|
||||
} = useTemplateCreation();
|
||||
|
||||
// Layout saving hook
|
||||
const {
|
||||
isSavingLayout,
|
||||
isModalOpen,
|
||||
openSaveModal,
|
||||
closeSaveModal,
|
||||
saveLayout,
|
||||
} = useLayoutSaving(slides);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const existingScript = document.querySelector('script[src*="tailwindcss.com"]');
|
||||
if (!existingScript) {
|
||||
const script = document.createElement("script");
|
||||
script.src = TAILWIND_CDN_URL;
|
||||
script.async = true;
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
/**
|
||||
* Step 1: Check fonts in uploaded PPTX
|
||||
*/
|
||||
const handleCheckFonts = useCallback(async () => {
|
||||
|
||||
|
||||
if (selectedFile) {
|
||||
await checkFonts(selectedFile);
|
||||
}
|
||||
}, [selectedFile, checkFonts]);
|
||||
|
||||
/**
|
||||
* Step 2: Upload fonts and generate preview
|
||||
*/
|
||||
const handleFontUploadAndPreview = useCallback(async () => {
|
||||
if (selectedFile) {
|
||||
const data = await fontUploadAndPreview(selectedFile);
|
||||
if (data) {
|
||||
useFontLoader(data.fonts);
|
||||
}
|
||||
}
|
||||
}, [selectedFile, fontUploadAndPreview]);
|
||||
|
||||
/**
|
||||
* Step 5: Save template with metadata
|
||||
*/
|
||||
const handleSaveTemplate = useCallback(async (
|
||||
layoutName: string,
|
||||
description: string,
|
||||
template_info_id: string
|
||||
): Promise<string | null> => {
|
||||
const id = await saveLayout(layoutName, description, template_info_id);
|
||||
if (id) {
|
||||
router.push(`/template-preview?slug=custom-${id}`);
|
||||
}
|
||||
return id;
|
||||
}, [saveLayout, router]);
|
||||
|
||||
/**
|
||||
* Update a specific slide's data
|
||||
*/
|
||||
const handleSlideUpdate = useCallback((index: number, updatedSlideData: Partial<ProcessedSlide>) => {
|
||||
setSlides((prevSlides) =>
|
||||
prevSlides.map((s, i) =>
|
||||
i === index
|
||||
? { ...s, ...updatedSlideData, modified: true }
|
||||
: s
|
||||
)
|
||||
);
|
||||
}, [setSlides]);
|
||||
|
||||
|
||||
/**
|
||||
* Open schema editor for a specific slide
|
||||
*/
|
||||
const handleOpenSchemaEditor = useCallback((index: number | null) => {
|
||||
setSchemaEditorSlideIndex(index);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Close schema editor
|
||||
*/
|
||||
const handleCloseSchemaEditor = useCallback(() => {
|
||||
setSchemaEditorSlideIndex(null);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Save changes from schema editor
|
||||
*/
|
||||
const handleSchemaEditorSave = useCallback((updatedReact: string) => {
|
||||
if (schemaEditorSlideIndex !== null) {
|
||||
setSlides(prev => prev.map((s, i) =>
|
||||
i === schemaEditorSlideIndex ? { ...s, react: updatedReact } : s
|
||||
));
|
||||
}
|
||||
setSchemaEditorSlideIndex(null);
|
||||
}, [schemaEditorSlideIndex, setSlides]);
|
||||
|
||||
/**
|
||||
* Update schema preview content (for AI fill)
|
||||
*/
|
||||
const handleSchemaPreviewContent = useCallback((content: Record<string, any>) => {
|
||||
if (schemaEditorSlideIndex !== null) {
|
||||
setSchemaPreviewData(prev => ({
|
||||
...prev,
|
||||
[schemaEditorSlideIndex]: content
|
||||
}));
|
||||
}
|
||||
}, [schemaEditorSlideIndex]);
|
||||
|
||||
/**
|
||||
* Clear schema preview data for a specific slide
|
||||
*/
|
||||
const handleClearSchemaPreview = useCallback((slideIndex: number) => {
|
||||
setSchemaPreviewData(prev => {
|
||||
const newData = { ...prev };
|
||||
delete newData[slideIndex];
|
||||
return newData;
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
const showFileUpload = state.step === 'file-upload';
|
||||
const showFontManager = state.step === 'font-check' || state.step === 'font-upload';
|
||||
const showPreview = state.step === 'slides-preview';
|
||||
const showSlides = state.step === 'template-creation' || state.step === 'completed';
|
||||
const isProcessingCompleted = state.step === 'completed';
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
|
||||
|
||||
<Header />
|
||||
<TemplateStudioHeader />
|
||||
{showFileUpload ? (
|
||||
<div className="pb-24">
|
||||
<FileUploadSection
|
||||
selectedFile={selectedFile}
|
||||
handleFileSelect={handleFileSelect}
|
||||
removeFile={removeFile}
|
||||
CheckFonts={handleCheckFonts}
|
||||
isProcessingPptx={state.isLoading}
|
||||
slides={[]}
|
||||
completedSlides={0}
|
||||
/>
|
||||
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-auto min-h-[600px] px-6 pb-24">
|
||||
|
||||
<TemplateCreationProgress
|
||||
currentStep={state.step}
|
||||
totalSlides={state.totalSlides}
|
||||
processedSlides={completedSlides}
|
||||
/>
|
||||
|
||||
{/* Step 2: Font Management */}
|
||||
{showFontManager && (
|
||||
<Step2FontManagement
|
||||
fontsData={state.fontsData}
|
||||
uploadedFonts={uploadedFonts}
|
||||
uploadFont={uploadFont}
|
||||
removeFont={removeFont}
|
||||
onContinue={handleFontUploadAndPreview}
|
||||
isUploading={state.isLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 3: Slide Preview */}
|
||||
{showPreview && (
|
||||
<Step3SlidePreview
|
||||
previewData={state.previewData}
|
||||
onInitTemplate={initTemplateCreation}
|
||||
isLoading={state.isLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 4: Template Creation & Editing */}
|
||||
{showSlides && slides.length > 0 && (
|
||||
<Step4TemplateCreation
|
||||
slides={slides}
|
||||
setSlides={setSlides}
|
||||
retrySlide={retrySlide}
|
||||
onSlideUpdate={handleSlideUpdate}
|
||||
schemaEditorSlideIndex={schemaEditorSlideIndex}
|
||||
onOpenSchemaEditor={handleOpenSchemaEditor}
|
||||
onCloseSchemaEditor={handleCloseSchemaEditor}
|
||||
onSchemaEditorSave={handleSchemaEditorSave}
|
||||
schemaPreviewData={schemaPreviewData}
|
||||
onSchemaPreviewContent={handleSchemaPreviewContent}
|
||||
onClearSchemaPreview={handleClearSchemaPreview}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Floating Save Template Button */}
|
||||
{isProcessingCompleted && slides.some((s) => s.processed) && (
|
||||
<SaveLayoutButton
|
||||
onSave={openSaveModal}
|
||||
isSaving={isSavingLayout}
|
||||
isProcessing={slides.some((s) => s.processing)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Save Template Modal */}
|
||||
<SaveLayoutModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={closeSaveModal}
|
||||
onSave={handleSaveTemplate}
|
||||
isSaving={isSavingLayout}
|
||||
template_info_id={state.templateId || ''}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomTemplatePage;
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import React from "react";
|
||||
import Header from "@/app/(presentation-generator)/(dashboard)/dashboard/components/Header";
|
||||
|
||||
export const APIKeyWarning: React.FC = () => {
|
||||
return (
|
||||
<div className="min-h-screen font-roboto bg-gradient-to-br from-slate-50 to-slate-100">
|
||||
<Header />
|
||||
<div className="flex items-center justify-center aspect-video mx-auto px-6">
|
||||
<div className="text-center space-y-2 my-6 bg-white p-10 rounded-lg shadow-lg">
|
||||
<h1 className="text-xl font-bold text-gray-900">
|
||||
Please add "GOOGLE_API_KEY" to enable template creation via AI.
|
||||
</h1>
|
||||
<h1 className="text-xl font-bold text-gray-900">Please add your OpenAI API Key to process the layout</h1>
|
||||
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
|
||||
This feature requires an OpenAI model GPT-5. Configure your key in settings or via environment variables.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,167 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Pencil, Eraser, RotateCcw, SendHorizontal, X } from "lucide-react";
|
||||
import { EditControlsProps } from "../../types";
|
||||
|
||||
export const EditControls: React.FC<EditControlsProps> = ({
|
||||
isEditMode,
|
||||
prompt,
|
||||
isUpdating,
|
||||
strokeWidth,
|
||||
strokeColor,
|
||||
eraserMode,
|
||||
onPromptChange,
|
||||
onSave,
|
||||
onCancel,
|
||||
onStrokeWidthChange,
|
||||
onStrokeColorChange,
|
||||
onEraserModeChange,
|
||||
onClearCanvas,
|
||||
}) => {
|
||||
const colors = [
|
||||
"#000000",
|
||||
"#FF0000",
|
||||
"#00FF00",
|
||||
"#0000FF",
|
||||
"#FFFF00",
|
||||
"#FF00FF",
|
||||
"#00FFFF",
|
||||
"#FFA500",
|
||||
];
|
||||
|
||||
const strokeWidths = [1, 3, 5, 8, 12];
|
||||
|
||||
if (!isEditMode) return null;
|
||||
|
||||
return (
|
||||
<div className="border-2 max-w-[1280px] mx-auto border-blue-200 rounded-lg p-4 bg-blue-50 space-y-4">
|
||||
{/* Drawing Tools */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
{/* Drawing Tools */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={!eraserMode ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onEraserModeChange(false)}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
Draw
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={eraserMode ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onEraserModeChange(true)}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Eraser size={14} />
|
||||
Erase
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Color Picker */}
|
||||
{!eraserMode && (
|
||||
<div className="flex items-center gap-1">
|
||||
{colors.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
className={`w-5 h-5 rounded-full border-2 ${
|
||||
strokeColor === color
|
||||
? "border-gray-800"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
style={{ backgroundColor: color }}
|
||||
onClick={() => onStrokeColorChange(color)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stroke Width */}
|
||||
<div className="flex items-center gap-1">
|
||||
{strokeWidths.map((width) => (
|
||||
<button
|
||||
key={width}
|
||||
className={`w-7 h-7 rounded border flex items-center justify-center ${
|
||||
strokeWidth === width
|
||||
? "bg-blue-100 border-blue-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
onClick={() => onStrokeWidthChange(width)}
|
||||
>
|
||||
<div
|
||||
className="rounded-full bg-gray-800"
|
||||
style={{
|
||||
width: `${width + 1}px`,
|
||||
height: `${width + 1}px`,
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onClearCanvas}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onCancel}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<X size={14} />
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Prompt Section */}
|
||||
<div className="space-y-2 mt-2">
|
||||
<label
|
||||
htmlFor="edit-prompt"
|
||||
className="text-sm font-medium font-inter text-gray-700"
|
||||
>
|
||||
Describe the changes you want to make:
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
id="edit-prompt"
|
||||
placeholder="Enter your prompt here... (e.g., 'Change the title color to blue', 'Add a border to the image', etc.)"
|
||||
value={prompt}
|
||||
onChange={(e) => onPromptChange(e.target.value)}
|
||||
className="flex-1 font-inter duration-300 h-[70px] border-blue-200 border-2 rounded-lg outline-none focus:border-blue-500 focus:ring-0 max-h-[70px] resize-none"
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
<div>
|
||||
<Button
|
||||
onClick={onSave}
|
||||
disabled={isUpdating || !prompt.trim()}
|
||||
className="flex flex-col w-28 font-inter font-semibold items-center gap-1 h-full bg-green-600 hover:bg-green-700 px-4"
|
||||
>
|
||||
{isUpdating ? (
|
||||
"Updating..."
|
||||
) : (
|
||||
<>
|
||||
<SendHorizontal size={14} />
|
||||
Update
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
'use client'
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Save, X, Code } from "lucide-react";
|
||||
import { ProcessedSlide } from "../../types";
|
||||
import Editor from 'react-simple-code-editor';
|
||||
import { highlight, languages } from 'prismjs';
|
||||
import 'prismjs/components/prism-clike';
|
||||
import 'prismjs/components/prism-javascript';
|
||||
import 'prismjs/components/prism-markup';
|
||||
import 'prismjs/components/prism-jsx';
|
||||
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
|
||||
interface HtmlEditorProps {
|
||||
slide: ProcessedSlide;
|
||||
isHtmlEditMode: boolean;
|
||||
onSave: (html: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const HtmlEditor: React.FC<HtmlEditorProps> = ({
|
||||
slide,
|
||||
isHtmlEditMode,
|
||||
onSave,
|
||||
onCancel,
|
||||
}) => {
|
||||
const [htmlContent, setHtmlContent] = useState(slide.html || "");
|
||||
|
||||
useEffect(() => {
|
||||
setHtmlContent(slide.html || "");
|
||||
}, [slide.html]);
|
||||
|
||||
if (!isHtmlEditMode) return null;
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(htmlContent);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setHtmlContent(slide.html || "");
|
||||
onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={isHtmlEditMode} onOpenChange={(open) => { if (!open) handleCancel(); }}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-[860px] p-0">
|
||||
<SheetHeader className="px-6 py-4 border-b">
|
||||
<SheetTitle className="flex items-center justify-between w-full">
|
||||
<span className="flex items-center gap-2 text-purple-800">
|
||||
<Code className="w-5 h-5 text-purple-600" />
|
||||
HTML Editor
|
||||
</span>
|
||||
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="space-y-4 px-2 overflow-y-auto h-[85%]">
|
||||
<div className="container__content_area">
|
||||
<Editor
|
||||
value={htmlContent}
|
||||
onValueChange={html => setHtmlContent(html)}
|
||||
highlight={code => highlight(code, languages.jsx!, 'jsx')}
|
||||
padding={10}
|
||||
id="html-editor"
|
||||
name="html-editor"
|
||||
className="container__editor"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SheetFooter className="px-6 py-4 border-b">
|
||||
<SheetTitle className="flex items-center justify-between w-full">
|
||||
<div></div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCancel}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<X size={14} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
className="flex items-center gap-1 bg-purple-600 hover:bg-purple-700"
|
||||
size="sm"
|
||||
>
|
||||
<Save size={14} />
|
||||
Save HTML
|
||||
</Button>
|
||||
</div>
|
||||
</SheetTitle>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,16 +1,32 @@
|
|||
'use client'
|
||||
|
||||
import React from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useDrawingCanvas } from "../../hooks/useDrawingCanvas";
|
||||
|
||||
import React, { useRef, useState, useMemo, useEffect } from "react";
|
||||
import { useCompiledLayout } from "../../hooks/useCompiledLayout";
|
||||
import { useSlideUndoRedo } from "../../hooks/useSlideUndoRedo";
|
||||
import { EachSlideProps } from "../../types";
|
||||
import { SlideContentDisplay } from "./SlideContentDisplay";
|
||||
import { useHtmlEdit } from "../../hooks/useHtmlEdit";
|
||||
import { SlideActions } from "./SlideActions";
|
||||
import { HtmlEditor } from "./HtmlEditor";
|
||||
import { EditControls } from "./EditControls";
|
||||
import { useSlideEdit } from "../../hooks/useSlideEdit";
|
||||
import {
|
||||
Trash2,
|
||||
X,
|
||||
Check,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
Sparkles,
|
||||
Edit,
|
||||
Code,
|
||||
MousePointer2,
|
||||
Undo,
|
||||
Redo
|
||||
} from "lucide-react";
|
||||
import Timer from "../Timer";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
|
||||
import ToolTip from "@/components/ToolTip";
|
||||
// import { CodeEditor } from "./CodeEditor";
|
||||
// import SlideSelectionEditor from "./SlideSelectionEditor";
|
||||
import SchemaElementHighlighter from "../SchemaElementHighlighter";
|
||||
|
||||
|
||||
const EachSlide: React.FC<EachSlideProps> = ({
|
||||
slide,
|
||||
|
|
@ -19,145 +35,451 @@ const EachSlide: React.FC<EachSlideProps> = ({
|
|||
setSlides,
|
||||
onSlideUpdate,
|
||||
isProcessing,
|
||||
onOpenSchemaEditor,
|
||||
isSchemaEditorOpen = false,
|
||||
schemaPreviewData,
|
||||
onClearSchemaPreview,
|
||||
}) => {
|
||||
// Custom hooks
|
||||
const [localPreviewData, setLocalPreviewData] = useState<Record<string, any> | null>(null);
|
||||
|
||||
// Use schema preview data from parent if available, otherwise use local
|
||||
const previewData = schemaPreviewData ?? localPreviewData;
|
||||
const setPreviewData = setLocalPreviewData;
|
||||
const [isEditPromptOpen, setIsEditPromptOpen] = useState(false);
|
||||
const slideDisplayRef = useRef<HTMLDivElement>(null);
|
||||
const [showCodeEditor, setShowCodeEditor] = useState(false);
|
||||
const [isSelectionEditMode, setIsSelectionEditMode] = useState(false);
|
||||
|
||||
// Compile layout once and share with child components
|
||||
const compiledLayout = useCompiledLayout(slide.react);
|
||||
|
||||
// Auto-retry once if compilation fails
|
||||
const hasAutoRetriedCompile = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset the flag when compilation succeeds
|
||||
if (compiledLayout) {
|
||||
hasAutoRetriedCompile.current = false;
|
||||
}
|
||||
}, [compiledLayout]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
slide.react &&
|
||||
slide.processed &&
|
||||
!slide.processing &&
|
||||
!compiledLayout &&
|
||||
!hasAutoRetriedCompile.current
|
||||
) {
|
||||
hasAutoRetriedCompile.current = true;
|
||||
console.log(`Auto-retrying slide ${index + 1} after compile failure...`);
|
||||
retrySlide(index);
|
||||
}
|
||||
}, [slide.react, slide.processed, slide.processing, compiledLayout, index, retrySlide]);
|
||||
|
||||
// Get sample data for schema-element highlighting
|
||||
const sampleData = useMemo(() => {
|
||||
if (previewData) return previewData;
|
||||
if (compiledLayout?.sampleData && Object.keys(compiledLayout.sampleData).length > 0) {
|
||||
return compiledLayout.sampleData;
|
||||
}
|
||||
try {
|
||||
return compiledLayout?.schema?.parse({}) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [compiledLayout, previewData]);
|
||||
|
||||
// Undo/Redo functionality for this slide
|
||||
const {
|
||||
canvasRef,
|
||||
slideDisplayRef,
|
||||
strokeWidth,
|
||||
strokeColor,
|
||||
eraserMode,
|
||||
isDrawing,
|
||||
canvasDimensions,
|
||||
setCanvasDimensions,
|
||||
didYourDraw,
|
||||
handleMouseDown,
|
||||
handleMouseMove,
|
||||
handleMouseUp,
|
||||
handleTouchStart,
|
||||
handleTouchMove,
|
||||
handleTouchEnd,
|
||||
handleClearCanvas,
|
||||
handleEraserModeChange,
|
||||
handleStrokeColorChange,
|
||||
handleStrokeWidthChange,
|
||||
} = useDrawingCanvas();
|
||||
undo,
|
||||
redo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
} = useSlideUndoRedo(slide, setSlides, index);
|
||||
|
||||
const {
|
||||
isEditMode,
|
||||
isUpdating,
|
||||
prompt,
|
||||
slideContentRef,
|
||||
setPrompt,
|
||||
handleSave,
|
||||
handleEditClick,
|
||||
handleCancelEdit,
|
||||
} = useSlideEdit(slide, index, onSlideUpdate, setSlides);
|
||||
|
||||
const {
|
||||
isHtmlEditMode,
|
||||
handleHtmlEditClick,
|
||||
handleHtmlEditCancel,
|
||||
handleHtmlSave,
|
||||
} = useHtmlEdit(slide, index, onSlideUpdate, setSlides);
|
||||
|
||||
// Set canvas dimensions when entering edit mode
|
||||
React.useEffect(() => {
|
||||
if (isEditMode && slideContentRef.current && slide.html) {
|
||||
const rect = slideContentRef.current.getBoundingClientRect();
|
||||
setCanvasDimensions({
|
||||
width: Math.max(rect.width, 800),
|
||||
height: Math.max(rect.height, 600),
|
||||
});
|
||||
}
|
||||
}, [isEditMode, slide.html, slideContentRef, setCanvasDimensions]);
|
||||
|
||||
// Handle save with drawing data
|
||||
const handleSaveWithDrawing = () => {
|
||||
handleSave(slideDisplayRef!, didYourDraw);
|
||||
};
|
||||
|
||||
// Handle delete slide
|
||||
const handleDeleteSlide = () => {
|
||||
setSlides((prevSlides) => prevSlides.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// Handle retry slide
|
||||
const handleRetrySlide = () => {
|
||||
retrySlide(index);
|
||||
};
|
||||
|
||||
const closeEditPrompt = () => {
|
||||
setIsEditPromptOpen(false);
|
||||
handleCancelEdit();
|
||||
};
|
||||
|
||||
const submitEditPrompt = async () => {
|
||||
|
||||
if (isUpdating) return;
|
||||
|
||||
await handleSave();
|
||||
setIsEditPromptOpen(false);
|
||||
setPrompt("");
|
||||
|
||||
};
|
||||
|
||||
// Clear preview data - clears both local and parent state
|
||||
const handleClearPreview = () => {
|
||||
setPreviewData(null);
|
||||
onClearSchemaPreview?.();
|
||||
};
|
||||
|
||||
|
||||
|
||||
// Handle delete slide
|
||||
const handleDeleteSlide = () => {
|
||||
// warmin
|
||||
const confirmed = window.confirm(
|
||||
`Are you sure you want to delete slide ${index + 1}? This action cannot be undone.`
|
||||
);
|
||||
if (!confirmed) return;
|
||||
setSlides(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// Handle selection edit update
|
||||
const handleSelectionUpdate = (updatedHtml: string) => {
|
||||
// Update the slide's html content via parent callback or directly
|
||||
setSlides(prev => prev.map((s, i) => i === index ? { ...s, react: updatedHtml } : s));
|
||||
};
|
||||
|
||||
const isSlideReady = slide.processed && !slide.processing;
|
||||
const isSlideProcessing = slide.processing;
|
||||
const hasError = !!slide.error;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={slide.slide_number}
|
||||
className="border-2 font-instrument_sans w-full relative"
|
||||
>
|
||||
<CardHeader className="max-w-[1280px] mx-auto px-0 py-6">
|
||||
<CardTitle className="text-xl">
|
||||
<SlideActions
|
||||
<div className="group max-w-[1440px] mx-auto relative bg-white rounded-2xl border border-[#E5E7EB] overflow-hidden transition-all duration-300 hover:shadow-lg hover:border-[#D1D5DB]">
|
||||
{/* Slide Header */}
|
||||
<div className="px-5 py-4 border-b border-[#F3F4F6] bg-gradient-to-r from-[#FAFAFA] to-white">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Left: Slide Info */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-[#EBE9FE] text-[#7A5AF8] font-semibold text-sm">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-[#111827] tracking-tight">
|
||||
{compiledLayout?.layoutId || `Slide ${index + 1}`}
|
||||
</h3>
|
||||
{compiledLayout?.layoutDescription && (
|
||||
<p className="text-sm text-[#6B7280] mt-0.5 line-clamp-1 max-w-[300px]">
|
||||
{compiledLayout.layoutDescription}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Actions */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Primary Actions Group */}
|
||||
<div className="flex items-center bg-gray-50/80 rounded-lg p-1 gap-0.5">
|
||||
{/* AI Edit Button */}
|
||||
<Popover
|
||||
open={isEditPromptOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsEditPromptOpen(open);
|
||||
if (open) handleEditClick();
|
||||
else handleCancelEdit();
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
disabled={!isSlideReady}
|
||||
className={`
|
||||
inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium
|
||||
rounded-md transition-all duration-150
|
||||
${!isSlideReady
|
||||
? "opacity-40 cursor-not-allowed text-gray-400"
|
||||
: "text-gray-600 hover:bg-white hover:text-violet-600 hover:shadow-sm"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
<span>AI Edit</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
side="bottom"
|
||||
sideOffset={8}
|
||||
className="w-[380px] p-0 rounded-xl border border-gray-200 shadow-2xl bg-white"
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center shadow-sm">
|
||||
<Sparkles className="w-3.5 h-3.5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-semibold text-gray-800">AI Edit</span>
|
||||
<p className="text-[10px] text-gray-400">Apply AI edits & tweaks</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeEditPrompt}
|
||||
disabled={isUpdating}
|
||||
className="p-1 rounded-md hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
rows={3}
|
||||
autoFocus
|
||||
placeholder="What changes would you like? e.g., 'Make the title larger' or 'Change colors to blue theme'"
|
||||
disabled={isUpdating}
|
||||
className="w-full px-3 py-2.5 rounded-lg border border-gray-200 bg-gray-50 text-sm text-gray-800 placeholder:text-gray-400 resize-none focus:outline-none focus:ring-2 focus:ring-violet-500/20 focus:border-violet-400 focus:bg-white transition-all"
|
||||
/>
|
||||
|
||||
<div className="flex justify-end mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitEditPrompt}
|
||||
disabled={isUpdating || !prompt.trim()}
|
||||
className={`
|
||||
inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-lg transition-all
|
||||
${isUpdating || !prompt.trim()
|
||||
? "bg-gray-100 text-gray-400 cursor-not-allowed"
|
||||
: "bg-gradient-to-r from-violet-500 to-purple-600 text-white hover:from-violet-600 hover:to-purple-700 shadow-sm hover:shadow-md"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isUpdating ? (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
Applying...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
Apply
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Schema Button */}
|
||||
<ToolTip content="Edit content schema">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isSchemaEditorOpen) {
|
||||
onOpenSchemaEditor?.(null);
|
||||
} else {
|
||||
onOpenSchemaEditor?.(index);
|
||||
}
|
||||
}}
|
||||
disabled={!isSlideReady}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md transition-all duration-150 disabled:opacity-40 disabled:cursor-not-allowed ${isSchemaEditorOpen
|
||||
? "bg-emerald-100 text-emerald-700"
|
||||
: "text-gray-600 hover:bg-white hover:text-emerald-600 hover:shadow-sm"
|
||||
}`}
|
||||
>
|
||||
<Edit className="w-3.5 h-3.5" />
|
||||
<span>Schema</span>
|
||||
</button>
|
||||
</ToolTip>
|
||||
|
||||
{/* Code Button */}
|
||||
{/* <ToolTip content="Edit source code">
|
||||
<button
|
||||
onClick={() => setShowCodeEditor(true)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md text-gray-600 hover:bg-white hover:text-blue-600 hover:shadow-sm transition-all duration-150"
|
||||
>
|
||||
<Code className="w-3.5 h-3.5" />
|
||||
<span>Code</span>
|
||||
</button>
|
||||
</ToolTip> */}
|
||||
|
||||
{/* Select Edit Button */}
|
||||
{/* <ToolTip content={isSelectionEditMode ? "Exit selection mode" : "Click elements to edit"}>
|
||||
<button
|
||||
onClick={() => setIsSelectionEditMode(!isSelectionEditMode)}
|
||||
disabled={!isSlideReady}
|
||||
className={`
|
||||
inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium
|
||||
rounded-md transition-all duration-150
|
||||
${isSelectionEditMode
|
||||
? "bg-indigo-100 text-indigo-700"
|
||||
: "text-gray-600 hover:bg-white hover:text-indigo-600 hover:shadow-sm"
|
||||
}
|
||||
disabled:opacity-40 disabled:cursor-not-allowed
|
||||
`}
|
||||
>
|
||||
<MousePointer2 className="w-3.5 h-3.5" />
|
||||
<span>{isSelectionEditMode ? "Exit" : "Select"}</span>
|
||||
</button>
|
||||
</ToolTip> */}
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-px h-6 bg-gray-200 mx-1" />
|
||||
|
||||
{/* Undo/Redo Group */}
|
||||
<div className="flex items-center bg-gray-50/80 rounded-lg p-1 gap-0.5">
|
||||
<ToolTip content={canUndo ? "Undo (Ctrl+Z)" : "Nothing to undo"}>
|
||||
<button
|
||||
onClick={undo}
|
||||
disabled={!canUndo || !isSlideReady}
|
||||
className={`
|
||||
inline-flex items-center justify-center w-8 h-8
|
||||
rounded-md transition-all duration-150
|
||||
${!canUndo || !isSlideReady
|
||||
? "opacity-40 cursor-not-allowed text-gray-400"
|
||||
: "text-gray-600 hover:bg-white hover:text-amber-600 hover:shadow-sm"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Undo className="w-4 h-4" />
|
||||
</button>
|
||||
</ToolTip>
|
||||
<ToolTip content={canRedo ? "Redo (Ctrl+Shift+Z)" : "Nothing to redo"}>
|
||||
<button
|
||||
onClick={redo}
|
||||
disabled={!canRedo || !isSlideReady}
|
||||
className={`
|
||||
inline-flex items-center justify-center w-8 h-8
|
||||
rounded-md transition-all duration-150
|
||||
${!canRedo || !isSlideReady
|
||||
? "opacity-40 cursor-not-allowed text-gray-400"
|
||||
: "text-gray-600 hover:bg-white hover:text-amber-600 hover:shadow-sm"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Redo className="w-4 h-4" />
|
||||
</button>
|
||||
</ToolTip>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-px h-6 bg-gray-200 mx-1" />
|
||||
|
||||
{/* Re-Construct Button */}
|
||||
<ToolTip content="Re-Design this slide">
|
||||
<button
|
||||
onClick={handleRetrySlide}
|
||||
disabled={!isSlideReady}
|
||||
className={`
|
||||
inline-flex items-center gap-2 px-4 py-2 text-sm font-medium
|
||||
rounded-full transition-all duration-200
|
||||
${!isSlideReady
|
||||
? "opacity-40 cursor-not-allowed bg-gradient-to-r from-[#F3F4F6] to-[#E5E7EB] text-[#9CA3AF]"
|
||||
: "text-[#111827] shadow-sm hover:shadow-md"
|
||||
}
|
||||
`}
|
||||
style={isSlideReady ? {
|
||||
background: 'linear-gradient(135deg, #D5CAFC 0%, #E3D2EB 35%, #F4DCD3 70%, #FDE4C2 100%)',
|
||||
} : undefined}
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
Re-Construct
|
||||
</button>
|
||||
|
||||
</ToolTip>
|
||||
|
||||
{/* Delete Button */}
|
||||
<ToolTip content="Delete slide">
|
||||
<button
|
||||
onClick={handleDeleteSlide}
|
||||
disabled={!isSlideReady}
|
||||
className={`
|
||||
p-1.5 rounded-lg border transition-all duration-150
|
||||
${!isSlideReady
|
||||
? "opacity-40 cursor-not-allowed bg-gray-50 border-gray-200 text-gray-400"
|
||||
: "bg-white border-gray-200 text-gray-400 hover:bg-red-50 hover:border-red-200 hover:text-red-500"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</ToolTip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Processing Timer - Only show here, not in SlideContentDisplay */}
|
||||
{isSlideProcessing && (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-[#7A5AF8]" />
|
||||
<span className="text-sm font-medium text-[#7A5AF8]">Generating slide layout...</span>
|
||||
</div>
|
||||
<Timer duration={120} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Slide Content */}
|
||||
<div className="p-4">
|
||||
{/* Selection Edit Mode Banner */}
|
||||
{isSelectionEditMode && slide.processed && !slide.processing && (
|
||||
<div className="mb-4 flex items-center justify-between bg-indigo-50 border border-indigo-200 rounded-xl px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded-full bg-indigo-500 flex items-center justify-center">
|
||||
<MousePointer2 className="w-3.5 h-3.5 text-white" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-indigo-700">
|
||||
Selection Edit Mode — Click on any element to edit with AI
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsSelectionEditMode(false)}
|
||||
className="h-8 px-3 text-sm font-medium text-indigo-600 hover:text-indigo-800 hover:bg-indigo-100 rounded-md transition-colors"
|
||||
>
|
||||
Exit
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative">
|
||||
<SlideContentDisplay
|
||||
slide={slide}
|
||||
index={index}
|
||||
isProcessing={isProcessing}
|
||||
isEditMode={isEditMode}
|
||||
isHtmlEditMode={isHtmlEditMode}
|
||||
onEditClick={handleEditClick}
|
||||
onHtmlEditClick={handleHtmlEditClick}
|
||||
onRetry={handleRetrySlide}
|
||||
onDelete={handleDeleteSlide}
|
||||
compiledLayout={compiledLayout}
|
||||
previewData={previewData}
|
||||
retrySlide={handleRetrySlide}
|
||||
onClearPreview={handleClearPreview}
|
||||
slideDisplayRef={slideDisplayRef}
|
||||
/>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
{/* Schema-Element Highlighting Overlay - active when schema editor is open */}
|
||||
{isSchemaEditorOpen && slide.processed && !slide.processing && (
|
||||
<SchemaElementHighlighter
|
||||
containerRef={slideDisplayRef}
|
||||
sampleData={sampleData}
|
||||
isActive={isSchemaEditorOpen}
|
||||
/>
|
||||
)}
|
||||
{/* Selection Editor Overlay */}
|
||||
{/* {isSelectionEditMode && slide.processed && !slide.processing && (
|
||||
<SlideSelectionEditor
|
||||
containerRef={slideDisplayRef}
|
||||
slide={slide}
|
||||
onSlideUpdate={handleSelectionUpdate}
|
||||
/>
|
||||
)} */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{/* HTML Editor */}
|
||||
<HtmlEditor
|
||||
slide={slide}
|
||||
isHtmlEditMode={isHtmlEditMode}
|
||||
onSave={handleHtmlSave}
|
||||
onCancel={handleHtmlEditCancel}
|
||||
/>
|
||||
|
||||
{/* Edit Controls */}
|
||||
<EditControls
|
||||
isEditMode={isEditMode}
|
||||
prompt={prompt}
|
||||
isUpdating={isUpdating}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeColor={strokeColor}
|
||||
eraserMode={eraserMode}
|
||||
onPromptChange={setPrompt}
|
||||
onSave={handleSaveWithDrawing}
|
||||
onCancel={handleCancelEdit}
|
||||
onStrokeWidthChange={handleStrokeWidthChange}
|
||||
onStrokeColorChange={handleStrokeColorChange}
|
||||
onEraserModeChange={handleEraserModeChange}
|
||||
onClearCanvas={handleClearCanvas}
|
||||
/>
|
||||
{/* Slide Content Display */}
|
||||
<SlideContentDisplay
|
||||
slide={slide}
|
||||
isEditMode={isEditMode}
|
||||
isHtmlEditMode={isHtmlEditMode}
|
||||
slideContentRef={slideContentRef}
|
||||
slideDisplayRef={slideDisplayRef}
|
||||
canvasRef={canvasRef}
|
||||
canvasDimensions={canvasDimensions}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeColor={strokeColor}
|
||||
eraserMode={eraserMode}
|
||||
isDrawing={isDrawing}
|
||||
didYourDraw={didYourDraw}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
retrySlide={handleRetrySlide}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Status Indicator */}
|
||||
{hasError && (
|
||||
<div className="absolute top-3 right-3">
|
||||
<div className="w-3 h-3 rounded-full bg-[#EF4444] animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EachSlide;
|
||||
export default EachSlide;
|
||||
|
|
|
|||
|
|
@ -1,103 +0,0 @@
|
|||
'use client'
|
||||
import React from "react";
|
||||
import { AlertCircle, CheckCircle, Edit, Loader2, Repeat2, Trash, Code } from "lucide-react";
|
||||
import ToolTip from "@/components/ToolTip";
|
||||
import { SlideActionsProps } from "../../types";
|
||||
|
||||
export const SlideActions: React.FC<SlideActionsProps> = ({
|
||||
slide,
|
||||
index,
|
||||
isProcessing,
|
||||
isEditMode,
|
||||
isHtmlEditMode,
|
||||
onEditClick,
|
||||
onHtmlEditClick,
|
||||
onRetry,
|
||||
onDelete,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center w-full justify-between gap-2">
|
||||
<div>
|
||||
{slide.processing ? (
|
||||
<Loader2 className="w-6 h-6 text-blue-600 animate-spin" />
|
||||
) : slide.processed ? (
|
||||
<CheckCircle className="w-6 h-6 text-green-600" />
|
||||
) : slide.error ? (
|
||||
<AlertCircle className="w-6 h-6 text-red-600" />
|
||||
) : (
|
||||
<div className="w-6 h-6 border-2 border-gray-300 rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{slide.processed && (
|
||||
<div className="flex gap-6">
|
||||
{slide.processed && slide.html && !isEditMode && !isHtmlEditMode && (
|
||||
<>
|
||||
<div>
|
||||
<ToolTip content="Edit slide with AI">
|
||||
<button
|
||||
onClick={onEditClick}
|
||||
disabled={isProcessing || !slide.processed}
|
||||
className={`px-6 py-2 flex gap-2 text-sm items-center group-hover:scale-105 rounded-lg bg-[#5141e5] hover:shadow-md transition-all duration-300 cursor-pointer shadow-md ${
|
||||
isProcessing || !slide.processed
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<Edit className="w-4 sm:w-5 h-4 sm:h-5 text-white" />
|
||||
<span className="text-white">Edit Slide</span>
|
||||
</button>
|
||||
</ToolTip>
|
||||
</div>
|
||||
<div>
|
||||
<ToolTip content="Edit HTML directly">
|
||||
<button
|
||||
onClick={onHtmlEditClick}
|
||||
disabled={isProcessing || !slide.processed}
|
||||
className={`px-6 py-2 flex gap-2 text-sm items-center group-hover:scale-105 rounded-lg bg-purple-600 hover:bg-purple-700 hover:shadow-md transition-all duration-300 cursor-pointer shadow-md ${
|
||||
isProcessing || !slide.processed
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<Code className="w-4 sm:w-5 h-4 sm:h-5 text-white" />
|
||||
<span className="text-white">Edit HTML</span>
|
||||
</button>
|
||||
</ToolTip>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div>
|
||||
<ToolTip content="Re-Design this slide">
|
||||
<button
|
||||
onClick={onRetry}
|
||||
disabled={isProcessing || !slide.processed}
|
||||
className={`px-6 py-2 flex gap-2 text-sm items-center group-hover:scale-105 rounded-lg bg-[#5141e5] hover:shadow-md transition-all duration-300 cursor-pointer shadow-md ${
|
||||
isProcessing || !slide.processed
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<Repeat2 className="w-4 sm:w-5 h-4 sm:h-5 text-white" />
|
||||
<span className="text-white">Re-Construct</span>
|
||||
</button>
|
||||
</ToolTip>
|
||||
</div>
|
||||
<div>
|
||||
<ToolTip content="Delete Slide">
|
||||
<button
|
||||
disabled={isProcessing}
|
||||
onClick={onDelete}
|
||||
className={`px-4 py-2 flex gap-2 text-sm items-center group-hover:scale-105 rounded-lg hover:shadow-md transition-all duration-300 cursor-pointer shadow-md ${
|
||||
isProcessing ? "opacity-50 cursor-not-allowed" : ""
|
||||
}`}
|
||||
>
|
||||
<Trash className="w-4 sm:w-5 h-4 sm:h-5 text-red-500" />
|
||||
</button>
|
||||
</ToolTip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,127 +1,122 @@
|
|||
'use client'
|
||||
|
||||
import React from "react";
|
||||
|
||||
import SlideContent from "../SlideContent";
|
||||
import { SlideContentDisplayProps } from "../../types";
|
||||
import { Repeat2 } from "lucide-react";
|
||||
import Timer from "../Timer";
|
||||
import { ProcessedSlide } from "../../types";
|
||||
import { RotateCcw, X, AlertCircle, ImageOff } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CompiledLayout } from "@/app/hooks/compileLayout";
|
||||
|
||||
export interface SlideContentDisplayProps {
|
||||
slide: ProcessedSlide;
|
||||
compiledLayout: CompiledLayout | null;
|
||||
previewData?: Record<string, any> | null;
|
||||
retrySlide: (slideNumber: number) => void;
|
||||
onClearPreview?: () => void;
|
||||
slideDisplayRef?: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export const SlideContentDisplay: React.FC<SlideContentDisplayProps> = ({
|
||||
slide,
|
||||
isEditMode,
|
||||
isHtmlEditMode,
|
||||
slideContentRef,
|
||||
slideDisplayRef,
|
||||
canvasRef,
|
||||
canvasDimensions,
|
||||
eraserMode,
|
||||
strokeWidth,
|
||||
strokeColor,
|
||||
isDrawing,
|
||||
didYourDraw,
|
||||
onMouseDown,
|
||||
onMouseMove,
|
||||
onMouseUp,
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
compiledLayout,
|
||||
previewData,
|
||||
retrySlide,
|
||||
onClearPreview,
|
||||
slideDisplayRef,
|
||||
}) => {
|
||||
// Don't show slide content when in HTML edit mode
|
||||
if (isHtmlEditMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (slide.processing) {
|
||||
// Successfully processed slide
|
||||
if (slide.processed && slide.react && !slide.processing) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-base text-blue-600 font-medium">🔄 Converting to HTML...</p>
|
||||
<div className="space-y-3">
|
||||
<Timer duration={160} />
|
||||
</div>
|
||||
<div className="animate-pulse space-y-3">
|
||||
<div className="h-6 bg-gray-200 rounded w-2/3"></div>
|
||||
<div className="h-6 bg-gray-200 rounded w-1/2"></div>
|
||||
<div className="h-64 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (slide.processed && slide.html) {
|
||||
return (
|
||||
<div className="relative">
|
||||
{slide.convertingToReact && (
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-purple-700 font-medium mb-1">⚙️ Converting HTML to React...</p>
|
||||
<Timer duration={90} />
|
||||
<div className="relative flex-1">
|
||||
{/* Preview Mode Banner */}
|
||||
{previewData && (
|
||||
<div className="mb-4 flex items-center justify-between bg-[#EDE9FE] border border-[#C4B5FD] rounded-xl px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded-full bg-[#7A5AF8] flex items-center justify-center">
|
||||
<span className="text-white text-xs">✨</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-[#5B21B6]">
|
||||
Showing AI-generated preview
|
||||
</span>
|
||||
</div>
|
||||
{onClearPreview && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClearPreview}
|
||||
className="h-8 text-[#7A5AF8] hover:text-[#5B21B6] hover:bg-[#DDD6FE]"
|
||||
>
|
||||
<X className="w-4 h-4 mr-1.5" />
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div ref={slideDisplayRef} className="relative mx-auto w-full">
|
||||
<div ref={slideContentRef}>
|
||||
<SlideContent slide={slide} />
|
||||
</div>
|
||||
{isEditMode && (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={canvasDimensions.width}
|
||||
height={canvasDimensions.height}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
zIndex: 30,
|
||||
cursor: eraserMode ? "grab" : "crosshair",
|
||||
pointerEvents: "auto",
|
||||
touchAction: "none",
|
||||
}}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseUp={onMouseUp}
|
||||
onMouseLeave={onMouseUp}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
|
||||
{/* Slide Content */}
|
||||
<div className="relative rounded-xl overflow-hidden border border-[#E5E7EB] bg-white shadow-sm">
|
||||
<div ref={slideDisplayRef}>
|
||||
<SlideContent
|
||||
slide={slide}
|
||||
compiledLayout={compiledLayout}
|
||||
data={previewData}
|
||||
retrySlide={retrySlide}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (slide.error) {
|
||||
const isImageTooLarge = slide.error.includes("image exceeds 5 MB maximum");
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-base text-red-600 font-medium">✗ Conversion failed</p>
|
||||
<div className="text-sm text-gray-700 p-4 bg-red-50 rounded border border-red-200">
|
||||
{slide.error.includes("image exceeds 5 MB maximum") ? (
|
||||
<div>
|
||||
<p className="font-medium text-red-700 mb-2">Image too large for processing</p>
|
||||
<p>This slide's image exceeds the 5MB limit. Try using a smaller resolution PPTX file.</p>
|
||||
</div>
|
||||
) : (
|
||||
slide.error
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<button className="bg-red-50 flex gap-2 items-center rounded border border-red-200 px-4 py-2 " onClick={() => retrySlide(slide.slide_number)}>
|
||||
<Repeat2 className="w-4 h-4" />Retry
|
||||
</button>
|
||||
<div className="rounded-xl border border-[#FECACA] bg-[#FEF2F2] p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-full bg-[#FEE2E2] flex items-center justify-center flex-shrink-0">
|
||||
{isImageTooLarge ? (
|
||||
<ImageOff className="w-5 h-5 text-[#DC2626]" />
|
||||
) : (
|
||||
<AlertCircle className="w-5 h-5 text-[#DC2626]" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-base font-semibold text-[#991B1B] mb-1">
|
||||
{isImageTooLarge ? "Image Too Large" : "Conversion Failed"}
|
||||
</h4>
|
||||
<p className="text-sm text-[#B91C1C] mb-4">
|
||||
{isImageTooLarge
|
||||
? "This slide's image exceeds the 5MB limit. Try using a smaller resolution PPTX file or compressing the images."
|
||||
: slide.error
|
||||
}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => retrySlide(slide.slide_number)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-full bg-white border border-[#FECACA] text-[#DC2626] hover:bg-[#FEE2E2] transition-all"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading/Processing state - Timer is now shown in parent component (NewEachSlide)
|
||||
// This just shows a skeleton placeholder
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-base text-gray-500">⏳ Waiting in queue to process...</p>
|
||||
<div className="animate-pulse space-y-3">
|
||||
<div className="h-6 bg-gray-200 rounded w-2/3"></div>
|
||||
<div className="h-6 bg-gray-200 rounded w-1/2"></div>
|
||||
<div className="h-64 bg-gray-200 rounded"></div>
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] p-6 mx-auto max-w-[1280px] w-full aspect-video h-[720px]">
|
||||
<div className="animate-pulse space-y-4 w-full h-full">
|
||||
|
||||
|
||||
{/* Content skeleton */}
|
||||
<div className="aspect-video bg-[#E5E7EB] rounded-xl mt-4 w-full h-full" />
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,117 +1,251 @@
|
|||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Upload, FileText, X, Loader2 } from "lucide-react";
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { UploadIcon, ChevronRight, Plus, FileText, X, Coins, Edit3, Info } from "lucide-react";
|
||||
import { ProcessedSlide } from "../types";
|
||||
import Timer from "./Timer";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "@/store/store";
|
||||
|
||||
interface FileUploadSectionProps {
|
||||
selectedFile: File | null;
|
||||
handleFileSelect: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
removeFile: () => void;
|
||||
processFile: () => void;
|
||||
CheckFonts: () => void;
|
||||
|
||||
isProcessingPptx: boolean;
|
||||
slides: ProcessedSlide[];
|
||||
completedSlides: number;
|
||||
}
|
||||
|
||||
// Credit costs constants
|
||||
const COST_PER_SLIDE = 3;
|
||||
const COST_EDIT = 1;
|
||||
|
||||
export const FileUploadSection: React.FC<FileUploadSectionProps> = ({
|
||||
selectedFile,
|
||||
handleFileSelect,
|
||||
removeFile,
|
||||
processFile,
|
||||
CheckFonts,
|
||||
|
||||
isProcessingPptx,
|
||||
slides,
|
||||
completedSlides,
|
||||
}) => {
|
||||
const isProcessing = isProcessingPptx || slides.some((s) => s.processing);
|
||||
const [isAllowed, setIsAllowed] = useState(false);
|
||||
|
||||
const { llm_config } = useSelector((state: RootState) => state.userConfig);
|
||||
|
||||
|
||||
|
||||
const handleCheckFonts = () => {
|
||||
|
||||
CheckFonts();
|
||||
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
|
||||
if (llm_config?.LLM === 'custom' || llm_config?.LLM === 'ollama') {
|
||||
setIsAllowed(false);
|
||||
} else {
|
||||
setIsAllowed(true);
|
||||
}
|
||||
}, [llm_config]);
|
||||
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Upload className="w-5 h-5" />
|
||||
Upload PDF or PPTX File
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Select a PDF or PowerPoint file (.pdf or .pptx) to process. Maximum file size: 100MB
|
||||
</CardDescription>
|
||||
{slides.length > 0 && (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{slides.some((s) => s.processing) && (
|
||||
<Loader2 className="w-6 h-6 animate-spin text-blue-600" />
|
||||
)}
|
||||
{completedSlides}/{slides.length} slides completed
|
||||
<div className="md:h-[calc(100vh-310px)] h-[calc(100vh-450px)] relative overflow-hidden">
|
||||
|
||||
<div className=" max-w-[650px] w-full mx-auto px-2 md:px-0 ">
|
||||
|
||||
<div
|
||||
className='absolute z-0 md:-bottom-[36%] -bottom-[40%] left-0 w-full h-full'
|
||||
style={{
|
||||
height: "341px",
|
||||
borderRadius: '1440px',
|
||||
background: 'radial-gradient(5.92% 104.69% at 50% 100%, rgba(122, 90, 248, 0.00) 0%, rgba(255, 255, 255, 0.00) 100%), radial-gradient(50% 50% at 50% 50%, rgba(122, 90, 248, 0.80) 0%, rgba(122, 90, 248, 0.00) 100%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className=' w-max ml-9 rounded-tl-[28px] rounded-tr-[28px] flex items-center bg-[#FAFAFF] px-2.5 pt-2.5 pb-1'
|
||||
style={{
|
||||
boxShadow: '0 0 16px 0 rgba(80, 71, 230, 0.12)',
|
||||
|
||||
}}
|
||||
>
|
||||
|
||||
<div className={`flex justify-center gap-1 py-2.5 pl-2 pr-3 cursor-pointer bg-white rounded-[80px] `}
|
||||
|
||||
style={{
|
||||
boxShadow: '0 0 4px 0 rgba(0, 0, 0, 0.06)',
|
||||
}}
|
||||
>
|
||||
<UploadIcon className={`w-4 h-4 text-black`} />
|
||||
<p className='text-xs font-medium text-black'>Upload PPTX File</p>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!selectedFile ? (
|
||||
<div className="border-2 relative border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-gray-400 transition-colors">
|
||||
<Upload className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||||
<Label htmlFor="file-upload" className="cursor-pointer">
|
||||
<span className="text-lg font-medium text-gray-700">
|
||||
Click to upload a PDF or PPTX file
|
||||
</span>
|
||||
<input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
accept=".pdf,.pptx"
|
||||
onChange={handleFileSelect}
|
||||
className="opacity-0 w-full h-full cursor-pointer absolute top-0 left-0 z-10"
|
||||
/>
|
||||
</Label>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
Drag and drop your file here or click to browse
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="w-8 h-8 text-blue-600" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">
|
||||
{selectedFile.name}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{(selectedFile.size / (1024 * 1024)).toFixed(2)} MB
|
||||
</p>
|
||||
</div>
|
||||
<div className=" w-full bg-[#FAFAFF] rounded-[28px] p-2.5 "
|
||||
style={{
|
||||
boxShadow: '0 0 16px 0 rgba(80, 71, 230, 0.12)',
|
||||
clipPath: 'inset(0px -28px -28px -28px)',
|
||||
}}
|
||||
>
|
||||
<div className="bg-[#FEFEFF] rounded-[18px] p-2 border border-[#EDEEEF] ">
|
||||
<div className="h-[120px] w-full bg-[#F6F6F9] rounded-[12px] p-1.5">
|
||||
<div className="border border-[#B8B8C1] border-dashed rounded-[12px ] p-1.5 h-full relative">
|
||||
{!selectedFile ? <>
|
||||
<input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
accept=".pptx"
|
||||
disabled={!isAllowed}
|
||||
onChange={handleFileSelect}
|
||||
className={`opacity-0 w-full h-full ${!isAllowed ? 'cursor-not-allowed' : 'cursor-pointer'} absolute top-0 left-0 z-10`}
|
||||
/>
|
||||
<div className='absolute inset-0 flex flex-col items-center justify-center'>
|
||||
<div className='w-[42px] h-[42px] flex justify-center items-center rounded-full bg-[#EBE9FE]' >
|
||||
<div className='w-[22px] h-[22px] rounded-full bg-[#7A5AF8] flex items-center justify-center text-white'>
|
||||
<Plus className='w-3 h-3' />
|
||||
</div>
|
||||
</div>
|
||||
<p className='pt-3 text-xs font-normal text-[#808080] tracking-[-0.12px] text-center'>
|
||||
<span className='text-[#808080] underline underline-offset-4'>Click to Upload</span> or drag & drop.
|
||||
</p>
|
||||
</div>
|
||||
</> : <div className="flex gap-2 items-center justify-center h-full">
|
||||
<div className="flex gap-2 items-center">
|
||||
|
||||
<div className="w-[55px] h-[55px] ml-auto mr-0 rounded-[9px] bg-[#8E8F8F] flex items-center justify-center relative">
|
||||
<button className="absolute w-[16px] h-[16px] flex items-center justify-center -top-1.5 -right-1.5"
|
||||
style={{
|
||||
borderRadius: '54.545px',
|
||||
border: '0.682px solid #EDEEEF',
|
||||
background: '#FFF',
|
||||
boxShadow: '0 4px 8px 0 rgba(0, 0, 0, 0.25)',
|
||||
}}
|
||||
disabled={isProcessing}
|
||||
onClick={removeFile}
|
||||
>
|
||||
<X className="w-3 h-3 text-black " />
|
||||
</button>
|
||||
|
||||
<FileText className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="w-4/5">
|
||||
<h3 className="text-[#4C4C4C] text-sm font-medium w-full truncate"> {selectedFile.name}</h3>
|
||||
<p className="text-xs font-normal text-[#808080] tracking-[-0.12px]">Presentation ( {(selectedFile.size / (1024 * 1024)).toFixed(2)} MB)</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={removeFile}
|
||||
disabled={
|
||||
isProcessingPptx || slides.some((s) => s.processing)
|
||||
}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center justify-between gap-2.5">
|
||||
<div className="min-w-[140px] w-full">
|
||||
{isProcessing ? (
|
||||
<div className="flex items-center justify-end gap-3" aria-live="polite" aria-label="Processing">
|
||||
<div
|
||||
className="h-[14px] w-[74px] rounded-full bg-[#EFEDFF] overflow-hidden ring-1 ring-[#E4E0FF]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="h-full w-full rounded-full processing-stripes" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-[#9A9AA6] tracking-[-0.1px]">Processing</p>
|
||||
{slides.length > 0 ? (
|
||||
<p className="text-sm font-medium text-[#9A9AA6] tracking-[-0.1px]">
|
||||
{completedSlides}/{slides.length} Slides
|
||||
</p>
|
||||
) : null}
|
||||
<style jsx>{`
|
||||
@keyframes stripes {
|
||||
from {
|
||||
background-position: 0 0;
|
||||
}
|
||||
to {
|
||||
background-position: 24px 0;
|
||||
}
|
||||
}
|
||||
.processing-stripes {
|
||||
background: repeating-linear-gradient(
|
||||
135deg,
|
||||
rgba(122, 90, 248, 0.9) 0px,
|
||||
rgba(122, 90, 248, 0.9) 9px,
|
||||
rgba(122, 90, 248, 0.18) 9px,
|
||||
rgba(122, 90, 248, 0.18) 18px
|
||||
);
|
||||
filter: saturate(1.05);
|
||||
background-size: 24px 24px;
|
||||
will-change: background-position;
|
||||
animation: stripes 0.7s linear infinite;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-end gap-2.5">
|
||||
|
||||
<button className="px-4 py-2.5 text-xs font-semibold text-[#101323] font-syne tracking-[-0.12px] flex gap-1"
|
||||
style={{
|
||||
borderRadius: '48px',
|
||||
background: 'linear-gradient(270deg, #D5CAFC 2.4%, #E3D2EB 27.88%, #F4DCD3 69.23%, #FDE4C2 100%)',
|
||||
cursor: !isAllowed ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
onClick={handleCheckFonts}
|
||||
disabled={isProcessing || !isAllowed}
|
||||
>
|
||||
{isProcessingPptx
|
||||
? "Checking Fonts..."
|
||||
: !selectedFile
|
||||
? "Select a PPTX file"
|
||||
: "Check Fonts"}
|
||||
<ChevronRight className="w-3.5 h-3.5 text-black" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 ">
|
||||
<Button
|
||||
onClick={processFile}
|
||||
disabled={isProcessingPptx || slides.some((s) => s.processing)}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
{isProcessingPptx
|
||||
? "Extracting Slides..."
|
||||
: !selectedFile
|
||||
? "Select a PDF or PPTX file"
|
||||
: "Process File"}
|
||||
</Button>
|
||||
{isProcessingPptx && <Timer duration={90} />}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ul className="flex items-center max-w-[85%] md:max-w-[70%] mx-auto mt-5 justify-between gap-2.5">
|
||||
<li className="flex items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8.5" cy="8.17041" r="4.5" fill="#EBE9FE" />
|
||||
</svg>
|
||||
<p className="md:text-sm text-[10px] font-normal text-[#3A3A3A] ">PPTX. Only</p>
|
||||
</li>
|
||||
<li className="flex items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8.5" cy="8.17041" r="4.5" fill="#EBE9FE" />
|
||||
</svg>
|
||||
<p className="md:text-sm text-[10px] font-normal text-[#3A3A3A] ">Max 100MB</p>
|
||||
</li>
|
||||
<li className="flex items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8.5" cy="8.17041" r="4.5" fill="#EBE9FE" />
|
||||
</svg>
|
||||
<p className="md:text-sm text-[10px] font-normal text-[#3A3A3A] ">5min Generation</p>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className="mt-4 px-4 py-3 rounded-lg border border-[#EBE9FE] flex items-start gap-2 shadow-md">
|
||||
<svg className="mt-0.5 shrink-0" xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 20 20" fill="none">
|
||||
<circle cx="10" cy="10" r="10" fill="#EBE9FE" />
|
||||
<path d="M10 6V10M10 14H10.0088" stroke="#5B49A1" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
<p className="text-sm md:text-base font-medium text-[#20165C] tracking-[-0.13px]">
|
||||
<span className="font-bold text-[#5B49A1]">Note:</span> Template generation relies on <span className="font-semibold">vision-capable models</span> and is currently supported only by providers: <span className="font-medium text-[#5246C3]">Google</span>, <span className="font-medium text-[#5246C3]">OpenAI</span>, and <span className="font-medium text-[#5246C3]">Anthropic</span>.
|
||||
For optimal results, use state-of-the-art models from these providers, as performance may degrade with smaller models.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
);
|
||||
};
|
||||
|
|
@ -1,77 +1,44 @@
|
|||
import React, { useState, useRef } from "react";
|
||||
import React, { useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Upload,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
X,
|
||||
Loader2,
|
||||
Type,
|
||||
ChevronRight,
|
||||
FileType,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
|
||||
interface UploadedFont {
|
||||
fontName: string;
|
||||
fontUrl: string;
|
||||
fontPath: string;
|
||||
}
|
||||
|
||||
interface FontData {
|
||||
internally_supported_fonts: {
|
||||
name: string;
|
||||
google_fonts_url: string;
|
||||
}[];
|
||||
not_supported_fonts: string[];
|
||||
}
|
||||
|
||||
interface FontManagerProps {
|
||||
fontsData: FontData;
|
||||
UploadedFonts: UploadedFont[];
|
||||
uploadFont: (fontName: string, file: File) => Promise<string | null>;
|
||||
removeFont: (fontUrl: string) => void;
|
||||
getAllUnsupportedFonts: () => string[];
|
||||
processSlideToHtml: () => void;
|
||||
}
|
||||
import { FontManagerProps, FontItem } from "../types";
|
||||
|
||||
const FontManager: React.FC<FontManagerProps> = ({
|
||||
fontsData,
|
||||
UploadedFonts,
|
||||
uploadedFonts,
|
||||
uploadFont,
|
||||
removeFont,
|
||||
getAllUnsupportedFonts,
|
||||
processSlideToHtml,
|
||||
onContinue,
|
||||
isUploading = false,
|
||||
}) => {
|
||||
const [uploadingFonts, setUploadingFonts] = useState<Set<string>>(new Set());
|
||||
const fileInputRefs = useRef<{ [key: string]: HTMLInputElement | null }>({});
|
||||
|
||||
const allUnsupportedFonts = getAllUnsupportedFonts();
|
||||
|
||||
// Filter out fonts that are already uploaded
|
||||
const fontsNeedingUpload = allUnsupportedFonts.filter(
|
||||
(fontName) =>
|
||||
!UploadedFonts.some((uploadedFont) => uploadedFont.fontName === fontName)
|
||||
// Get fonts that still need to be uploaded (unavailable fonts not yet uploaded)
|
||||
const fontsNeedingUpload = fontsData.unavailable_fonts.filter(
|
||||
(font) => !uploadedFonts.some((uploaded) => uploaded.fontName === font.name)
|
||||
);
|
||||
|
||||
const handleFontUpload = async (fontName: string, file: File) => {
|
||||
const allFontsUploaded = fontsNeedingUpload.length === 0;
|
||||
const hasAvailableFonts = fontsData.available_fonts.length > 0;
|
||||
const hasUploadedFonts = uploadedFonts.length > 0;
|
||||
|
||||
const handleFontUpload = (fontName: string, file: File) => {
|
||||
if (!file) return;
|
||||
|
||||
setUploadingFonts((prev) => new Set(prev).add(fontName));
|
||||
const result = uploadFont(fontName, file);
|
||||
|
||||
try {
|
||||
const fontUrl = await uploadFont(fontName, file);
|
||||
|
||||
if (fontUrl) {
|
||||
// Clear the file input
|
||||
if (fileInputRefs.current[fontName]) {
|
||||
fileInputRefs.current[fontName]!.value = "";
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setUploadingFonts((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(fontName);
|
||||
return newSet;
|
||||
});
|
||||
if (result && fileInputRefs.current[fontName]) {
|
||||
fileInputRefs.current[fontName]!.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -85,149 +52,188 @@ const FontManager: React.FC<FontManagerProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
if (allUnsupportedFonts.length === 0 && UploadedFonts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="my-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<Type className="w-6 h-6" />
|
||||
Font Management
|
||||
</CardTitle>
|
||||
<p className="text-sm text-gray-600">
|
||||
We couldn't load these fonts automatically. Please upload them manually. Make sure naem of the font should be exactly as shown.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Supported Fonts */}
|
||||
{fontsData.internally_supported_fonts.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-green-700 mb-3 flex items-center gap-1">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
Supported Fonts ({fontsData.internally_supported_fonts.length})
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{fontsData.internally_supported_fonts.map((font, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="p-2 bg-green-50 border border-green-200 rounded text-sm text-green-800"
|
||||
>
|
||||
{font.name}
|
||||
</div>
|
||||
))}
|
||||
<div className="my-8 max-w-[900px] mx-auto">
|
||||
<div className="bg-white rounded-2xl border border-[#E5E7EB] shadow-sm overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-5 border-b border-[#F3F4F6] bg-[#FAFAFA]">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-[#EBE9FE] flex items-center justify-center">
|
||||
<Type className="w-6 h-6 text-[#7A5AF8]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-[#111827]">Font Management</h2>
|
||||
<p className="text-sm text-[#6B7280] mt-0.5">
|
||||
{allFontsUploaded
|
||||
? "All fonts are ready! You can proceed to preview."
|
||||
: "Upload missing fonts to ensure your presentation displays correctly."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fonts Needing Upload */}
|
||||
{fontsNeedingUpload.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-orange-700 mb-3 flex items-center gap-1">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
Fonts Needing Upload ({fontsNeedingUpload.length})
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{fontsNeedingUpload.map((fontName: string, index: number) => (
|
||||
<div
|
||||
key={index}
|
||||
className="p-4 bg-orange-50 border border-orange-200 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-orange-800">
|
||||
{fontName}
|
||||
</span>
|
||||
<p className="text-xs text-orange-600 mt-1">
|
||||
Required for presentation
|
||||
</p>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Available Fonts */}
|
||||
{hasAvailableFonts && (
|
||||
<div className="p-4 bg-[#F0FDF4] rounded-xl border border-[#BBF7D0]">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<CheckCircle2 className="w-5 h-5 text-[#16A34A]" />
|
||||
<h4 className="text-sm font-semibold text-[#166534]">
|
||||
Available Fonts ({fontsData.available_fonts.length})
|
||||
</h4>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{fontsData.available_fonts.map((font, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-3 py-1.5 bg-white border border-[#D1FAE5] rounded-full text-xs font-medium text-[#166534] shadow-sm"
|
||||
>
|
||||
{font.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fonts Needing Upload */}
|
||||
{fontsNeedingUpload.length > 0 && (
|
||||
<div className="p-4 bg-[#FFFBEB] rounded-xl border border-[#FDE68A]">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<AlertTriangle className="w-5 h-5 text-[#D97706]" />
|
||||
<h4 className="text-sm font-semibold text-[#92400E]">
|
||||
Missing Fonts ({fontsNeedingUpload.length})
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{fontsNeedingUpload.map((font, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-4 bg-white rounded-xl border border-[#FDE68A] shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-[#FEF3C7] flex items-center justify-center">
|
||||
<FileType className="w-5 h-5 text-[#D97706]" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-semibold text-[#111827] block">
|
||||
{font.name}
|
||||
</span>
|
||||
<span className="text-xs text-[#6B7280]">
|
||||
.ttf, .otf, .woff, .woff2
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div>
|
||||
<input
|
||||
ref={(el) => {
|
||||
fileInputRefs.current[fontName] = el;
|
||||
fileInputRefs.current[font.name] = el;
|
||||
}}
|
||||
type="file"
|
||||
accept=".ttf,.otf,.woff,.woff2,.eot"
|
||||
onChange={(e) => handleFileInputChange(fontName, e)}
|
||||
onChange={(e) => handleFileInputChange(font.name, e)}
|
||||
className="hidden"
|
||||
id={`global-font-upload-${index}`}
|
||||
id={`font-upload-${index}`}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={uploadingFonts.has(fontName)}
|
||||
onClick={() => fileInputRefs.current[fontName]?.click()}
|
||||
className="text-xs bg-blue-600 text-white hover:text-white hover:bg-blue-700 border-blue-600"
|
||||
onClick={() => fileInputRefs.current[font.name]?.click()}
|
||||
className="rounded-full px-4 h-9 text-sm font-medium transition-all text-[#D97706] border-[#D97706] hover:bg-[#FFFBEB] hover:border-[#D97706]"
|
||||
>
|
||||
{uploadingFonts.has(fontName) ? (
|
||||
<>
|
||||
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
|
||||
Uploading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-3 h-3 mr-1" />
|
||||
Upload Font
|
||||
</>
|
||||
)}
|
||||
<Upload className="w-4 h-4 mr-1" />
|
||||
Upload
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Successfully Uploaded Fonts */}
|
||||
{UploadedFonts.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-green-700 mb-3 flex items-center gap-1">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
Uploaded Fonts ({UploadedFonts.length})
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{UploadedFonts.map((font, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="p-3 bg-green-50 border border-green-200 rounded-lg flex items-center justify-between"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-green-800">
|
||||
{font.fontName}
|
||||
</span>
|
||||
<p className="text-xs text-green-600 mt-1">
|
||||
Available for all slides
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeFont(font.fontUrl)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50 p-1"
|
||||
{/* Uploaded Fonts */}
|
||||
{hasUploadedFonts && (
|
||||
<div className="p-4 bg-[#F0FDF4] rounded-xl border border-[#BBF7D0]">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<CheckCircle2 className="w-5 h-5 text-[#16A34A]" />
|
||||
<h4 className="text-sm font-semibold text-[#166534]">
|
||||
Uploaded Fonts ({uploadedFonts.length})
|
||||
</h4>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{uploadedFonts.map((font, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-3 bg-white rounded-xl border border-[#D1FAE5] shadow-sm"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-[#DCFCE7] flex items-center justify-center">
|
||||
<CheckCircle2 className="w-4 h-4 text-[#16A34A]" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-[#166534]">
|
||||
{font.fontName}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeFont(font.fontName)}
|
||||
className="p-2 rounded-full text-[#6B7280] hover:text-[#DC2626] hover:bg-[#FEE2E2] transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-center mt-4">
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={processSlideToHtml}
|
||||
className="text-xs px-8 py-2 font-semibold bg-blue-600 text-white hover:text-white hover:bg-blue-700 border-blue-600"
|
||||
>
|
||||
Extract Template
|
||||
</Button>
|
||||
{/* Action Footer */}
|
||||
<div className={`px-6 py-5 border-t transition-colors duration-300 ${allFontsUploaded
|
||||
? 'bg-[#F0FDF4] border-[#BBF7D0]'
|
||||
: 'bg-[#FAFAFA] border-[#F3F4F6]'
|
||||
}`}>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
{!allFontsUploaded && (
|
||||
<div className="flex items-start gap-2 text-sm text-[#6B7280]">
|
||||
<Info className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||
<p>You can continue without all fonts, but some text may not display correctly.</p>
|
||||
</div>
|
||||
)}
|
||||
{allFontsUploaded && (
|
||||
<p className="text-sm text-[#16A34A] font-medium">
|
||||
✓ All fonts are ready
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={onContinue}
|
||||
disabled={isUploading}
|
||||
className={`
|
||||
px-5 py-2 h-auto text-sm font-semibold rounded-full transition-all duration-300
|
||||
${isUploading
|
||||
? 'bg-[#E5E7EB] text-[#9CA3AF]'
|
||||
: allFontsUploaded
|
||||
? 'bg-[#16A34A] text-white hover:bg-[#15803D] shadow-sm'
|
||||
: 'bg-white text-[#374151] border border-[#E5E7EB] hover:bg-[#F9FAFB]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{allFontsUploaded ? 'Continue to Preview' : 'Continue'}
|
||||
<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
'use client'
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FileText, Loader2 } from "lucide-react";
|
||||
|
|
@ -13,13 +14,18 @@ export const SaveLayoutButton: React.FC<SaveLayoutButtonProps> = ({
|
|||
isSaving,
|
||||
isProcessing,
|
||||
}) => {
|
||||
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50">
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 p-2"
|
||||
style={{
|
||||
borderRadius: '36px',
|
||||
background: 'rgba(0, 0, 0, 0.28)',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={onSave}
|
||||
disabled={isSaving || isProcessing}
|
||||
className="bg-green-600 hover:bg-green-700 text-white shadow-lg hover:shadow-xl transition-all duration-200 px-10 py-3 text-lg"
|
||||
className="bg-[#6938EF] hover:bg-[#6938EF]/90 rounded-[24px] text-white shadow-lg hover:shadow-xl transition-all duration-200 p-3.5 text-base font-semibold"
|
||||
size="lg"
|
||||
>
|
||||
{isSaving ? (
|
||||
|
|
@ -29,7 +35,7 @@ export const SaveLayoutButton: React.FC<SaveLayoutButtonProps> = ({
|
|||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileText className="w-5 h-5 mr-2" />
|
||||
|
||||
Save as Template
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
|
||||
'use client'
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -11,14 +13,16 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Loader2, Save } from "lucide-react";
|
||||
import { Loader2, Save, Info } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import ToolTip from "@/components/ToolTip";
|
||||
|
||||
interface SaveLayoutModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (layoutName: string, description: string) => Promise<string | null>;
|
||||
onSave: (layoutName: string, description: string, template_info_id: string) => Promise<string | null>;
|
||||
isSaving: boolean;
|
||||
template_info_id: string;
|
||||
}
|
||||
|
||||
export const SaveLayoutModal: React.FC<SaveLayoutModalProps> = ({
|
||||
|
|
@ -26,23 +30,20 @@ export const SaveLayoutModal: React.FC<SaveLayoutModalProps> = ({
|
|||
onClose,
|
||||
onSave,
|
||||
isSaving,
|
||||
template_info_id,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
|
||||
const [layoutName, setLayoutName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!layoutName.trim()) {
|
||||
return; // Don't save if name is empty
|
||||
}
|
||||
const id = await onSave(layoutName.trim(), description.trim());
|
||||
if (id) {
|
||||
// Redirect to the new template preview page
|
||||
router.push(`/template-preview/custom-${id}`);
|
||||
}
|
||||
// Reset form after navigation decision
|
||||
setLayoutName("");
|
||||
setDescription("");
|
||||
await onSave(layoutName.trim(), description.trim(), template_info_id);
|
||||
|
||||
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
|
|
@ -55,44 +56,57 @@ export const SaveLayoutModal: React.FC<SaveLayoutModalProps> = ({
|
|||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
|
||||
<DialogContent className="sm:max-w-[480px] " style={{ zIndex: 1000 }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Save className="w-5 h-5 text-green-600" />
|
||||
Save Template
|
||||
<DialogTitle className="flex items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<Save className="w-5 h-5 text-primary" />
|
||||
Save Template
|
||||
</span>
|
||||
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter a name and description for your template. This will help you identify it later.
|
||||
Give your template a clear name and an optional description to find it later.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-5 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="layout-name" className="text-sm font-medium">
|
||||
Template Name *
|
||||
Template Name <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="layout-name"
|
||||
value={layoutName}
|
||||
onChange={(e) => setLayoutName(e.target.value)}
|
||||
placeholder="Enter template name..."
|
||||
placeholder="e.g., Modern Tech Pitch"
|
||||
disabled={isSaving}
|
||||
className="w-full"
|
||||
aria-required
|
||||
/>
|
||||
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description" className="text-sm font-medium">
|
||||
Description
|
||||
Description <span className="text-gray-400">(optional)</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Enter a description for your template..."
|
||||
placeholder="Add a short summary of what this template is best for..."
|
||||
disabled={isSaving}
|
||||
className="w-full resize-none"
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
</div>
|
||||
{isSaving && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="lucide lucide-clock-icon lucide-clock"><path d="M12 6v6l4 2"><animateTransform attributeName="transform" type="rotate" from="0 12 12" to="360 12 12" dur="10s" repeatCount="indefinite" /></path><circle cx="12" cy="12" r="10" /></svg>
|
||||
<span>Saving your template. This may take a moment…</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
|
|
@ -106,6 +120,7 @@ export const SaveLayoutModal: React.FC<SaveLayoutModalProps> = ({
|
|||
onClick={handleSave}
|
||||
disabled={isSaving || !layoutName.trim()}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
aria-busy={isSaving}
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
|
|
@ -121,6 +136,7 @@ export const SaveLayoutModal: React.FC<SaveLayoutModalProps> = ({
|
|||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* Schema Editor Panel Component
|
||||
* Wraps the SchemaEditor with compiled layout functionality
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { ProcessedSlide } from "../types";
|
||||
import { SchemaEditor } from "./SchemaEditor";
|
||||
import { useCompiledLayout } from "../hooks/useCompiledLayout";
|
||||
|
||||
interface SchemaEditorPanelProps {
|
||||
slide: ProcessedSlide;
|
||||
slideIndex: number;
|
||||
onSave: (updatedReact: string) => void;
|
||||
onCancel: () => void;
|
||||
onFillContent?: (content: Record<string, any>) => void;
|
||||
}
|
||||
|
||||
export const SchemaEditorPanel: React.FC<SchemaEditorPanelProps> = ({
|
||||
slide,
|
||||
slideIndex,
|
||||
onSave,
|
||||
onCancel,
|
||||
onFillContent,
|
||||
}) => {
|
||||
const compiledLayout = useCompiledLayout(slide.react);
|
||||
|
||||
return (
|
||||
<SchemaEditor
|
||||
slide={slide}
|
||||
compiledLayout={compiledLayout}
|
||||
isOpen={true}
|
||||
onSave={onSave}
|
||||
onCancel={onCancel}
|
||||
onFillContent={onFillContent}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,307 @@
|
|||
'use client'
|
||||
|
||||
import React, { useCallback, useEffect, useState, useMemo } from 'react'
|
||||
import { useSchemaHighlight, getAllValuesAtPath } from './SchemaHighlightContext'
|
||||
|
||||
interface HighlightRect {
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
path: string
|
||||
}
|
||||
|
||||
interface SchemaElementHighlighterProps {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>
|
||||
sampleData: Record<string, any> | null
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
const SchemaElementHighlighter: React.FC<SchemaElementHighlighterProps> = ({
|
||||
containerRef,
|
||||
sampleData,
|
||||
isActive,
|
||||
}) => {
|
||||
const {
|
||||
highlightedSchemaPath,
|
||||
setHighlightedElementPath,
|
||||
|
||||
} = useSchemaHighlight()
|
||||
|
||||
const [highlightRects, setHighlightRects] = useState<HighlightRect[]>([])
|
||||
const [hoverRect, setHoverRect] = useState<HighlightRect | null>(null)
|
||||
|
||||
// Build a map of text content to schema paths
|
||||
const textToPathMap = useMemo(() => {
|
||||
if (!sampleData) return new Map<string, string>()
|
||||
|
||||
const map = new Map<string, string>()
|
||||
|
||||
const buildMap = (obj: any, parentPath: string = '') => {
|
||||
if (!obj || typeof obj !== 'object') return
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
// Skip internal fields
|
||||
if (key.startsWith('__') || key.startsWith('_')) continue
|
||||
|
||||
const path = parentPath ? `${parentPath}.${key}` : key
|
||||
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
// Store the text content mapped to its path
|
||||
map.set(value.trim(), path)
|
||||
} else if (typeof value === 'number') {
|
||||
map.set(String(value), path)
|
||||
} else if (Array.isArray(value)) {
|
||||
// For arrays, map each item's content with array notation
|
||||
value.forEach((item, index) => {
|
||||
if (typeof item === 'object' && item !== null) {
|
||||
buildMap(item, `${path}[]`)
|
||||
} else if (typeof item === 'string' && item.trim().length > 0) {
|
||||
map.set(item.trim(), `${path}[]`)
|
||||
}
|
||||
})
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
buildMap(value, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildMap(sampleData)
|
||||
return map
|
||||
}, [sampleData])
|
||||
|
||||
// Find elements that contain the highlighted schema path's value
|
||||
const findElementsForPath = useCallback((path: string): HTMLElement[] => {
|
||||
const container = containerRef.current
|
||||
if (!container || !sampleData) return []
|
||||
|
||||
// Get all values for this path (handles arrays)
|
||||
const values = getAllValuesAtPath(sampleData, path)
|
||||
if (values.length === 0) return []
|
||||
|
||||
const elements: HTMLElement[] = []
|
||||
|
||||
// Walk through all text nodes and find matches
|
||||
const walker = document.createTreeWalker(
|
||||
container,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
null
|
||||
)
|
||||
|
||||
const valueSet = new Set(values.map(v => String(v).trim()))
|
||||
|
||||
let node: Text | null
|
||||
while ((node = walker.nextNode() as Text | null)) {
|
||||
const text = node.textContent?.trim()
|
||||
if (text && valueSet.has(text)) {
|
||||
const parent = node.parentElement
|
||||
if (parent && !parent.closest('[data-inspector-overlay="1"]')) {
|
||||
elements.push(parent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return elements
|
||||
}, [containerRef, sampleData])
|
||||
|
||||
// Find the schema path for an element based on its text content
|
||||
const findPathForElement = useCallback((element: HTMLElement): string | null => {
|
||||
const text = element.textContent?.trim()
|
||||
if (!text) return null
|
||||
|
||||
// Look up the text in our map
|
||||
return textToPathMap.get(text) || null
|
||||
}, [textToPathMap])
|
||||
|
||||
// Calculate highlight rectangles for the highlighted schema path
|
||||
useEffect(() => {
|
||||
if (!isActive || !highlightedSchemaPath) {
|
||||
setHighlightRects([])
|
||||
return
|
||||
}
|
||||
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const elements = findElementsForPath(highlightedSchemaPath)
|
||||
|
||||
|
||||
const rects: HighlightRect[] = elements.map(el => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
path: highlightedSchemaPath,
|
||||
}
|
||||
}).filter(r => r.width > 0 && r.height > 0)
|
||||
|
||||
setHighlightRects(rects)
|
||||
}, [highlightedSchemaPath, isActive, containerRef, findElementsForPath])
|
||||
|
||||
// Handle hover on elements to show which schema field they map to
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const handleMouseOver = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (!target || target.closest('[data-inspector-overlay="1"]')) {
|
||||
return
|
||||
}
|
||||
|
||||
const path = findPathForElement(target)
|
||||
if (path) {
|
||||
const rect = target.getBoundingClientRect()
|
||||
setHoverRect({
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
path,
|
||||
})
|
||||
} else {
|
||||
setHoverRect(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setHoverRect(null)
|
||||
}
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (!target || target.closest('[data-inspector-overlay="1"]')) {
|
||||
return
|
||||
}
|
||||
|
||||
const path = findPathForElement(target)
|
||||
if (path) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setHighlightedElementPath(path)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
container.addEventListener('mouseover', handleMouseOver, true)
|
||||
container.addEventListener('mouseleave', handleMouseLeave, true)
|
||||
container.addEventListener('click', handleClick, true)
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('mouseover', handleMouseOver, true)
|
||||
container.removeEventListener('mouseleave', handleMouseLeave, true)
|
||||
container.removeEventListener('click', handleClick, true)
|
||||
}
|
||||
}, [isActive, containerRef, findPathForElement, setHighlightedElementPath])
|
||||
|
||||
// Recalculate on scroll/resize
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
|
||||
const handleUpdate = () => {
|
||||
if (highlightedSchemaPath) {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const elements = findElementsForPath(highlightedSchemaPath)
|
||||
const rects: HighlightRect[] = elements.map(el => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
path: highlightedSchemaPath,
|
||||
}
|
||||
}).filter(r => r.width > 0 && r.height > 0)
|
||||
|
||||
setHighlightRects(rects)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', handleUpdate, true)
|
||||
window.addEventListener('resize', handleUpdate)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleUpdate, true)
|
||||
window.removeEventListener('resize', handleUpdate)
|
||||
}
|
||||
}, [isActive, highlightedSchemaPath, containerRef, findElementsForPath])
|
||||
|
||||
if (!isActive) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hover highlight - shows path label */}
|
||||
{hoverRect && !highlightedSchemaPath && (
|
||||
<>
|
||||
<div
|
||||
data-inspector-overlay="1"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: hoverRect.left - 2,
|
||||
top: hoverRect.top - 2,
|
||||
width: hoverRect.width + 4,
|
||||
height: hoverRect.height + 4,
|
||||
border: '2px dashed #10b981',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 40,
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
/>
|
||||
{/* Path label */}
|
||||
<div
|
||||
data-inspector-overlay="1"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: hoverRect.left,
|
||||
top: hoverRect.top - 24,
|
||||
backgroundColor: '#10b981',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
fontWeight: 600,
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 41,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{hoverRect.path}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Schema path highlights */}
|
||||
{highlightRects.map((rect, idx) => (
|
||||
<div
|
||||
key={`schema-highlight-${idx}`}
|
||||
data-inspector-overlay="1"
|
||||
className="animate-pulse"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: rect.left - 3,
|
||||
top: rect.top - 3,
|
||||
width: rect.width + 6,
|
||||
height: rect.height + 6,
|
||||
border: '3px solid #8b5cf6',
|
||||
backgroundColor: 'rgba(139, 92, 246, 0.15)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 40,
|
||||
borderRadius: '6px',
|
||||
boxShadow: '0 0 0 2px rgba(139, 92, 246, 0.3), 0 4px 12px rgba(139, 92, 246, 0.2)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SchemaElementHighlighter
|
||||
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
'use client'
|
||||
|
||||
import React, { createContext, useContext, useState, RefObject } from 'react'
|
||||
|
||||
interface SchemaHighlightContextType {
|
||||
// Currently highlighted schema path (from schema editor hover)
|
||||
highlightedSchemaPath: string | null
|
||||
setHighlightedSchemaPath: (path: string | null) => void
|
||||
|
||||
// Currently highlighted element path (from element click)
|
||||
highlightedElementPath: string | null
|
||||
setHighlightedElementPath: (path: string | null) => void
|
||||
|
||||
// Sample data for matching elements to schema paths
|
||||
sampleData: Record<string, any> | null
|
||||
setSampleData: (data: Record<string, any> | null) => void
|
||||
|
||||
// Container ref for the slide preview
|
||||
slideContainerRef: RefObject<HTMLDivElement | null> | null
|
||||
setSlideContainerRef: (ref: RefObject<HTMLDivElement | null> | null) => void
|
||||
|
||||
|
||||
}
|
||||
|
||||
const SchemaHighlightContext = createContext<SchemaHighlightContextType | null>(null)
|
||||
|
||||
export const SchemaHighlightProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [highlightedSchemaPath, setHighlightedSchemaPath] = useState<string | null>(null)
|
||||
const [highlightedElementPath, setHighlightedElementPath] = useState<string | null>(null)
|
||||
const [sampleData, setSampleData] = useState<Record<string, any> | null>(null)
|
||||
const [slideContainerRef, setSlideContainerRef] = useState<RefObject<HTMLDivElement | null> | null>(null)
|
||||
|
||||
|
||||
return (
|
||||
<SchemaHighlightContext.Provider value={{
|
||||
highlightedSchemaPath,
|
||||
setHighlightedSchemaPath,
|
||||
highlightedElementPath,
|
||||
setHighlightedElementPath,
|
||||
sampleData,
|
||||
setSampleData,
|
||||
slideContainerRef,
|
||||
setSlideContainerRef,
|
||||
|
||||
}}>
|
||||
{children}
|
||||
</SchemaHighlightContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useSchemaHighlight = () => {
|
||||
const context = useContext(SchemaHighlightContext)
|
||||
if (!context) {
|
||||
// Return a no-op version if not in provider
|
||||
return {
|
||||
highlightedSchemaPath: null,
|
||||
setHighlightedSchemaPath: () => { },
|
||||
highlightedElementPath: null,
|
||||
setHighlightedElementPath: () => { },
|
||||
sampleData: null,
|
||||
setSampleData: () => { },
|
||||
slideContainerRef: null,
|
||||
setSlideContainerRef: () => { },
|
||||
|
||||
}
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
// Helper to get value from nested path in data object
|
||||
export function getValueAtPath(data: Record<string, any> | null | undefined, path: string): any {
|
||||
if (!data || !path) return undefined
|
||||
|
||||
// Handle array item paths like "items[].title" - get from first item
|
||||
const parts = path.split('.')
|
||||
let current: any = data
|
||||
|
||||
for (const part of parts) {
|
||||
if (current === undefined || current === null) return undefined
|
||||
|
||||
// Check if this part has array notation
|
||||
if (part.endsWith('[]')) {
|
||||
const arrayKey = part.slice(0, -2)
|
||||
current = current[arrayKey]
|
||||
if (Array.isArray(current) && current.length > 0) {
|
||||
current = current[0] // Get first item for matching
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
} else {
|
||||
current = current[part]
|
||||
}
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
// Helper to find all values for a schema path (for arrays, returns all item values)
|
||||
export function getAllValuesAtPath(data: Record<string, any> | null | undefined, path: string): any[] {
|
||||
if (!data || !path) return []
|
||||
|
||||
const parts = path.split('.')
|
||||
let currentItems: any[] = [data]
|
||||
|
||||
for (const part of parts) {
|
||||
const nextItems: any[] = []
|
||||
|
||||
for (const item of currentItems) {
|
||||
if (item === undefined || item === null) continue
|
||||
|
||||
if (part.endsWith('[]')) {
|
||||
const arrayKey = part.slice(0, -2)
|
||||
const arr = item[arrayKey]
|
||||
if (Array.isArray(arr)) {
|
||||
nextItems.push(...arr)
|
||||
}
|
||||
} else {
|
||||
if (item[part] !== undefined) {
|
||||
nextItems.push(item[part])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentItems = nextItems
|
||||
}
|
||||
|
||||
return currentItems.filter(v => v !== undefined && v !== null)
|
||||
}
|
||||
|
||||
|
|
@ -1,21 +1,65 @@
|
|||
'use client'
|
||||
|
||||
import React, { memo } from "react";
|
||||
import { CompiledLayout, compileCustomLayout } from "@/app/hooks/compileLayout";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import React, { memo, useMemo } from "react";
|
||||
interface SlideContentProps {
|
||||
slide: any;
|
||||
data?: Record<string, any> | null;
|
||||
compiledLayout?: CompiledLayout | null;
|
||||
retrySlide: (slideNumber: number) => void;
|
||||
}
|
||||
|
||||
const SlideContent = memo(({ slide, data, compiledLayout, retrySlide }: SlideContentProps) => {
|
||||
|
||||
// Use provided compiled layout or compile (fallback for other usages)
|
||||
const module = useMemo(() => {
|
||||
if (compiledLayout) return compiledLayout;
|
||||
if (!slide.react) return null;
|
||||
return compileCustomLayout(slide.react);
|
||||
}, [slide.react, compiledLayout]);
|
||||
|
||||
const sampleData = useMemo(() => {
|
||||
// If custom data is provided, use it
|
||||
if (data) {
|
||||
|
||||
return data;
|
||||
|
||||
}
|
||||
// Otherwise use sampleData from compiled layout or generate from schema defaults
|
||||
if (module?.sampleData && Object.keys(module.sampleData).length > 0) {
|
||||
return module.sampleData;
|
||||
}
|
||||
try {
|
||||
return module?.schema?.parse({}) ?? {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}, [module, data]);
|
||||
|
||||
const Component = useMemo(() => {
|
||||
return module?.component;
|
||||
}, [module]);
|
||||
|
||||
if (!slide?.react) return null;
|
||||
if (!module) {
|
||||
return (
|
||||
<div className="w-full aspect-[16/9] h-[720px] bg-red-50 text-red-700 p-4 rounded border border-red-200 text-sm whitespace-pre-wrap break-words flex flex-col items-center justify-center">
|
||||
<p className="text-center"> Failed to render slide component. Check console for details.</p>
|
||||
<button onClick={() => retrySlide(slide.slide_number)} className="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 flex items-center justify-center mt-6"> <RotateCcw className="w-4 h-4 mr-1" /> Re-Construct</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const SlideContent = memo(({ slide }: { slide: any }) => {
|
||||
const cleanHtml = slide.html
|
||||
.replace(/```html/g, "")
|
||||
.replace(/```/g, "")
|
||||
.replace(/<html>/g, "")
|
||||
.replace(/<\/html>/g, "")
|
||||
.replace(/html/g, "");
|
||||
return (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: cleanHtml,
|
||||
}}
|
||||
/>
|
||||
|
||||
<>
|
||||
{Component && <Component data={sampleData} />}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
SlideContent.displayName = 'SlideContent';
|
||||
|
||||
export default SlideContent;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
'use client'
|
||||
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Loader2,
|
||||
Images,
|
||||
ChevronRight,
|
||||
Sparkles
|
||||
} from "lucide-react";
|
||||
import { SlidePreviewSectionProps } from "../types";
|
||||
import { resolveBackendAssetUrl } from '@/utils/api'
|
||||
|
||||
|
||||
export const SlidePreviewSection: React.FC<SlidePreviewSectionProps> = ({
|
||||
previewData,
|
||||
onInitTemplate,
|
||||
isLoading,
|
||||
}) => {
|
||||
const slideCount = previewData.slide_image_urls?.length || 0;
|
||||
|
||||
return (
|
||||
<div className="my-8 max-w-[1440px] mx-auto">
|
||||
{/* Header Card */}
|
||||
<div className="bg-white rounded-2xl border border-[#E5E7EB] shadow-sm overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-5 border-b border-[#F3F4F6] bg-gradient-to-r from-[#FAFAFA] to-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-[#EBE9FE] to-[#DDD6FE] flex items-center justify-center shadow-sm">
|
||||
<Images className="w-6 h-6 text-[#7A5AF8]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-[#111827]">Slide Preview</h2>
|
||||
<p className="text-sm text-[#6B7280] mt-0.5">
|
||||
{slideCount} slide{slideCount !== 1 ? 's' : ''} ready for template generation
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 py-4 max-h-[900px] overflow-y-auto">
|
||||
{previewData.slide_image_urls?.map((url, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="group relative aspect-video w-full max-w-[1280px] mx-auto rounded-xl overflow-hidden "
|
||||
>
|
||||
<img
|
||||
src={resolveBackendAssetUrl(url)}
|
||||
alt={`Slide ${index + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
{/* Slide number badge */}
|
||||
<div className="absolute top-2 left-2 px-2.5 py-1 bg-black/70 backdrop-blur-sm rounded-lg text-xs font-semibold text-white shadow-lg">
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
{/* Action Footer */}
|
||||
<div className="px-6 py-5 border-t border-[#F3F4F6] bg-gradient-to-r from-[#FAFAFA] to-white">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<p className="text-sm text-[#6B7280] max-w-md text-center sm:text-left">
|
||||
Ready to generate your template. Each slide will be converted to a reusable React component.
|
||||
</p>
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={onInitTemplate}
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 h-auto text-xs font-syne font-medium rounded-full shadow-lg hover:shadow-xl transition-all duration-300 "
|
||||
style={{
|
||||
background: isLoading
|
||||
? '#E5E7EB'
|
||||
: 'linear-gradient(135deg, #D5CAFC 0%, #E3D2EB 35%, #F4DCD3 70%, #FDE4C2 100%)',
|
||||
color: isLoading ? '#9CA3AF' : '#111827',
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
||||
Starting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
Generate Template
|
||||
<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
import React from "react";
|
||||
import {
|
||||
Upload,
|
||||
Type,
|
||||
Images,
|
||||
Sparkles,
|
||||
Check,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { TemplateCreationStep } from "../types";
|
||||
|
||||
interface TemplateCreationProgressProps {
|
||||
currentStep: TemplateCreationStep;
|
||||
totalSlides?: number;
|
||||
processedSlides?: number;
|
||||
}
|
||||
|
||||
interface StepConfig {
|
||||
id: TemplateCreationStep;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const steps: StepConfig[] = [
|
||||
{
|
||||
id: 'file-upload',
|
||||
label: 'Upload',
|
||||
icon: <Upload className="w-4 h-4" />
|
||||
},
|
||||
{
|
||||
id: 'font-check',
|
||||
label: 'Fonts',
|
||||
icon: <Type className="w-4 h-4" />
|
||||
},
|
||||
{
|
||||
id: 'slides-preview',
|
||||
label: 'Preview',
|
||||
icon: <Images className="w-4 h-4" />
|
||||
},
|
||||
{
|
||||
id: 'template-creation',
|
||||
label: 'Generate',
|
||||
icon: <Sparkles className="w-4 h-4" />
|
||||
},
|
||||
{
|
||||
id: 'completed',
|
||||
label: 'Done',
|
||||
icon: <Check className="w-4 h-4" />
|
||||
},
|
||||
];
|
||||
|
||||
export const TemplateCreationProgress: React.FC<TemplateCreationProgressProps> = ({
|
||||
currentStep,
|
||||
totalSlides = 0,
|
||||
processedSlides = 0,
|
||||
}) => {
|
||||
const getCurrentStepIndex = () => {
|
||||
if (currentStep === 'font-upload') return 1;
|
||||
const stepIndex = steps.findIndex(s => s.id === currentStep);
|
||||
return stepIndex >= 0 ? stepIndex : 0;
|
||||
};
|
||||
|
||||
const currentStepIndex = getCurrentStepIndex();
|
||||
|
||||
const getStepStatus = (stepIndex: number): 'completed' | 'current' | 'pending' => {
|
||||
if (currentStep === 'completed') return 'completed';
|
||||
if (stepIndex < currentStepIndex) return 'completed';
|
||||
if (stepIndex === currentStepIndex) return 'current';
|
||||
return 'pending';
|
||||
};
|
||||
|
||||
const progressPercentage = totalSlides > 0
|
||||
? Math.round((processedSlides / totalSlides) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-[700px] mx-auto mb-8">
|
||||
{/* Steps */}
|
||||
<div className="flex items-center justify-between">
|
||||
{steps.map((step, index) => {
|
||||
const status = getStepStatus(index);
|
||||
const isLast = index === steps.length - 1;
|
||||
|
||||
return (
|
||||
<React.Fragment key={step.id}>
|
||||
<div className="flex flex-col items-center">
|
||||
{/* Circle */}
|
||||
<div
|
||||
className={`
|
||||
w-10 h-10 rounded-full flex items-center justify-center border-2 transition-all duration-200
|
||||
${status === 'completed'
|
||||
? 'bg-[#7A5AF8] border-[#7A5AF8] text-white'
|
||||
: status === 'current'
|
||||
? 'bg-white border-[#7A5AF8] text-[#7A5AF8]'
|
||||
: 'bg-white border-[#E5E7EB] text-[#9CA3AF]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{status === 'completed' ? (
|
||||
<Check className="w-4 h-4" />
|
||||
) : status === 'current' && currentStep === 'template-creation' ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
step.icon
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<span
|
||||
className={`
|
||||
mt-2 text-xs font-medium
|
||||
${status === 'completed' || status === 'current'
|
||||
? 'text-[#374151]'
|
||||
: 'text-[#9CA3AF]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Connector */}
|
||||
{!isLast && (
|
||||
<div className="flex-1 h-px mx-3 -mt-5">
|
||||
<div
|
||||
className={`h-full transition-colors duration-200 ${getStepStatus(index + 1) !== 'pending'
|
||||
? 'bg-[#7A5AF8]'
|
||||
: 'bg-[#E5E7EB]'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Processing Progress */}
|
||||
{currentStep === 'template-creation' && totalSlides > 0 && (
|
||||
<div className="mt-6 p-4 bg-[#F9FAFB] rounded-xl border border-[#E5E7EB]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-[#374151]">
|
||||
Processing slides
|
||||
</span>
|
||||
<span className="text-sm font-medium text-[#374151]">
|
||||
{processedSlides} / {totalSlides}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full h-2 bg-[#E5E7EB] rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[#7A5AF8] rounded-full transition-all duration-300"
|
||||
style={{ width: `${progressPercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
|
||||
|
||||
import React from "react";
|
||||
|
||||
export const TemplateStudioHeader: React.FC = () => {
|
||||
return (
|
||||
<div className="text-center my-[52px] px-2 md:px-0">
|
||||
<h1 className="font-unbounded text-[36px] sm:text-[38px] md:text-[64px] text-[#101323] font-normal tracking-[-1.92px] pb-2">
|
||||
Template Studio
|
||||
</h1>
|
||||
<p className="text-[#101323CC] text-base md:text-xl font-syne font-normal max-w-[600px] mx-auto">
|
||||
Upload your PPTX file to extract slides and convert them to a template which you can use to generate AI presentations.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -14,34 +14,18 @@ const Timer = ({ duration }: TimerProps) => {
|
|||
// Guard against invalid durations
|
||||
const totalMs = Math.max(0, duration * 1000)
|
||||
|
||||
const easeOutCubic = (x: number) => 1 - Math.pow(1 - x, 3)
|
||||
const easeOutSine = (x: number) => Math.sin((x * Math.PI) / 2)
|
||||
|
||||
const tick = (now: number) => {
|
||||
if (startTimeRef.current === null) startTimeRef.current = now
|
||||
const elapsed = now - startTimeRef.current
|
||||
const t = totalMs === 0 ? 1 : Math.min(elapsed / totalMs, 1)
|
||||
|
||||
// Piecewise progression:
|
||||
// - Reach ~75% around 60% of the total duration (faster start)
|
||||
// - Then ease slowly towards 99% for the remainder
|
||||
let nextProgress: number
|
||||
if (t <= 0.6) {
|
||||
nextProgress = 75 * easeOutCubic(t / 0.6)
|
||||
} else {
|
||||
nextProgress = 75 + 24 * easeOutSine((t - 0.6) / 0.4)
|
||||
}
|
||||
setProgress(prev => (t <= prev ? prev : t))
|
||||
|
||||
// Clamp and ensure we never hit 100
|
||||
nextProgress = Math.min(99, nextProgress)
|
||||
|
||||
setProgress(prev => (nextProgress < prev ? prev : nextProgress))
|
||||
|
||||
if (t < 1 && nextProgress < 99) {
|
||||
if (t < 1) {
|
||||
rafIdRef.current = requestAnimationFrame(tick)
|
||||
} else {
|
||||
// End at 99 and stop
|
||||
setProgress(99)
|
||||
// Ensure we finish at 100%
|
||||
setProgress(1)
|
||||
if (rafIdRef.current) cancelAnimationFrame(rafIdRef.current)
|
||||
rafIdRef.current = null
|
||||
}
|
||||
|
|
@ -59,41 +43,55 @@ const Timer = ({ duration }: TimerProps) => {
|
|||
}
|
||||
}, [duration])
|
||||
|
||||
const progressValue = Math.min(1, Number(progress.toFixed(4)))
|
||||
const displayedProgress = Math.round(progressValue * 100)
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-2">
|
||||
<div className="flex justify-end items-center text-gray-800 text-sm">
|
||||
<span className="font-inter text-end font-semibold text-xs">{Math.round(progress)}%</span>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
{/* Progress bar container */}
|
||||
<div
|
||||
className="w-full rounded-full h-3 overflow-hidden shadow-inner"
|
||||
className="relative w-full h-2 rounded-full bg-[#E5E7EB] overflow-hidden"
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(progress)}
|
||||
aria-valuenow={displayedProgress}
|
||||
>
|
||||
<div className="relative h-full rounded-full" style={{
|
||||
width: `${progress}%`,
|
||||
backgroundImage: 'linear-gradient(90deg, #9034EA, #5146E5, #9034EA)',
|
||||
backgroundSize: '200% 100%',
|
||||
animation: 'gradient 2s linear infinite'
|
||||
}}>
|
||||
<div className="absolute inset-0 opacity-25" style={{
|
||||
backgroundImage:
|
||||
'linear-gradient(45deg, rgba(255,255,255,.8) 25%, transparent 25%, transparent 50%, rgba(255,255,255,.8) 50%, rgba(255,255,255,.8) 75%, transparent 75%, transparent)',
|
||||
backgroundSize: '16px 16px',
|
||||
animation: 'stripes 1s linear infinite'
|
||||
}} />
|
||||
{/* Progress fill */}
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full transition-[width] duration-100 ease-out"
|
||||
style={{
|
||||
width: `${progressValue * 100}%`,
|
||||
background: 'linear-gradient(90deg, #7A5AF8, #9B8AFB, #7A5AF8)',
|
||||
backgroundSize: '200% 100%',
|
||||
animation: 'shimmer 2s linear infinite',
|
||||
}}
|
||||
>
|
||||
{/* Animated stripes overlay */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-30"
|
||||
style={{
|
||||
backgroundImage: 'linear-gradient(45deg, rgba(255,255,255,0.4) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.4) 50%, rgba(255,255,255,0.4) 75%, transparent 75%, transparent)',
|
||||
backgroundSize: '12px 12px',
|
||||
animation: 'stripes 0.8s linear infinite',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute inset-0" />
|
||||
</div>
|
||||
|
||||
{/* Percentage text */}
|
||||
<div className="flex justify-end mt-1.5">
|
||||
<span className="text-xs font-medium text-[#6B7280] tabular-nums">
|
||||
{displayedProgress}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes gradient {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
@keyframes stripes {
|
||||
to { background-position: 16px 0; }
|
||||
to { background-position: 12px 0; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* Slides List Component
|
||||
* Renders the grid of slides being edited
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { ProcessedSlide } from "../../types";
|
||||
import SlideErrorBoundary from "@/app/(presentation-generator)/components/SlideErrorBoundary";
|
||||
import EachSlide from "../EachSlide/NewEachSlide";
|
||||
|
||||
interface SlidesListProps {
|
||||
slides: ProcessedSlide[];
|
||||
setSlides: React.Dispatch<React.SetStateAction<ProcessedSlide[]>>;
|
||||
retrySlide: (index: number) => void;
|
||||
onSlideUpdate: (index: number, updatedSlideData: Partial<ProcessedSlide>) => void;
|
||||
onOpenSchemaEditor: (index: number | null) => void;
|
||||
schemaEditorSlideIndex: number | null;
|
||||
schemaPreviewData: Record<number, Record<string, any>>;
|
||||
onClearSchemaPreview: (slideIndex: number) => void;
|
||||
isSchemaEditorOpen: boolean;
|
||||
}
|
||||
|
||||
export const SlidesList: React.FC<SlidesListProps> = ({
|
||||
slides,
|
||||
setSlides,
|
||||
retrySlide,
|
||||
onSlideUpdate,
|
||||
onOpenSchemaEditor,
|
||||
schemaEditorSlideIndex,
|
||||
schemaPreviewData,
|
||||
onClearSchemaPreview,
|
||||
isSchemaEditorOpen,
|
||||
}) => {
|
||||
const containerWidth = isSchemaEditorOpen ? 'w-[calc(100%-540px)]' : 'w-full';
|
||||
|
||||
return (
|
||||
<div className={`space-y-5 w-full p-5 rounded-2xl ${containerWidth}`}>
|
||||
{slides.map((slide, index) => (
|
||||
<SlideErrorBoundary
|
||||
key={index}
|
||||
label={`Slide ${index + 1}`}
|
||||
>
|
||||
<EachSlide
|
||||
key={index}
|
||||
slide={slide}
|
||||
index={index}
|
||||
isProcessing={slides.some((s) => s.processing)}
|
||||
retrySlide={retrySlide}
|
||||
setSlides={setSlides}
|
||||
onSlideUpdate={(updatedSlideData) =>
|
||||
onSlideUpdate(index, updatedSlideData)
|
||||
}
|
||||
onOpenSchemaEditor={onOpenSchemaEditor}
|
||||
isSchemaEditorOpen={schemaEditorSlideIndex === index}
|
||||
schemaPreviewData={schemaPreviewData[index] ?? null}
|
||||
onClearSchemaPreview={() => onClearSchemaPreview(index)}
|
||||
/>
|
||||
</SlideErrorBoundary>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* Step 2: Font Management
|
||||
* Handles font checking and uploading
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import FontManager from "../FontManager";
|
||||
import { FontData, UploadedFont } from "../../types";
|
||||
|
||||
interface Step2FontManagementProps {
|
||||
fontsData: FontData | null;
|
||||
uploadedFonts: UploadedFont[];
|
||||
uploadFont: (fontName: string, file: File) => string | null;
|
||||
removeFont: (fontName: string) => void;
|
||||
onContinue: () => Promise<void>;
|
||||
isUploading: boolean;
|
||||
}
|
||||
|
||||
export const Step2FontManagement: React.FC<Step2FontManagementProps> = ({
|
||||
fontsData,
|
||||
uploadedFonts,
|
||||
uploadFont,
|
||||
removeFont,
|
||||
onContinue,
|
||||
isUploading,
|
||||
}) => {
|
||||
if (!fontsData) return null;
|
||||
|
||||
return (
|
||||
<FontManager
|
||||
fontsData={fontsData}
|
||||
uploadedFonts={uploadedFonts}
|
||||
uploadFont={uploadFont}
|
||||
removeFont={removeFont}
|
||||
onContinue={onContinue}
|
||||
isUploading={isUploading}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* Step 3: Slide Preview
|
||||
* Displays preview of slides with uploaded fonts
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { SlidePreviewSection } from "../SlidePreviewSection";
|
||||
import { FontUploadPreviewResponse } from "../../types";
|
||||
|
||||
interface Step3SlidePreviewProps {
|
||||
previewData: FontUploadPreviewResponse | null;
|
||||
onInitTemplate: () => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const Step3SlidePreview: React.FC<Step3SlidePreviewProps> = ({
|
||||
previewData,
|
||||
onInitTemplate,
|
||||
isLoading,
|
||||
}) => {
|
||||
if (!previewData) return null;
|
||||
|
||||
return (
|
||||
<SlidePreviewSection
|
||||
previewData={previewData}
|
||||
onInitTemplate={onInitTemplate}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
|
||||
|
||||
import React from "react";
|
||||
import { ProcessedSlide } from "../../types";
|
||||
import { SchemaHighlightProvider } from "../SchemaHighlightContext";
|
||||
import { SlidesList } from "./SlidesList";
|
||||
import { SchemaEditorPanel } from "../SchemaEditorPanel";
|
||||
|
||||
interface Step4TemplateCreationProps {
|
||||
slides: ProcessedSlide[];
|
||||
setSlides: React.Dispatch<React.SetStateAction<ProcessedSlide[]>>;
|
||||
retrySlide: (index: number) => void;
|
||||
onSlideUpdate: (index: number, updatedSlideData: Partial<ProcessedSlide>) => void;
|
||||
|
||||
// Schema editor state
|
||||
schemaEditorSlideIndex: number | null;
|
||||
onOpenSchemaEditor: (index: number | null) => void;
|
||||
onCloseSchemaEditor: () => void;
|
||||
onSchemaEditorSave: (updatedReact: string) => void;
|
||||
|
||||
// Schema preview state
|
||||
schemaPreviewData: Record<number, Record<string, any>>;
|
||||
onSchemaPreviewContent: (content: Record<string, any>) => void;
|
||||
onClearSchemaPreview: (slideIndex: number) => void;
|
||||
}
|
||||
|
||||
export const Step4TemplateCreation: React.FC<Step4TemplateCreationProps> = ({
|
||||
slides,
|
||||
setSlides,
|
||||
retrySlide,
|
||||
onSlideUpdate,
|
||||
schemaEditorSlideIndex,
|
||||
onOpenSchemaEditor,
|
||||
onCloseSchemaEditor,
|
||||
onSchemaEditorSave,
|
||||
schemaPreviewData,
|
||||
onSchemaPreviewContent,
|
||||
onClearSchemaPreview,
|
||||
}) => {
|
||||
const schemaEditorSlide = schemaEditorSlideIndex !== null ? slides[schemaEditorSlideIndex] : null;
|
||||
const isSchemaEditorOpen = schemaEditorSlideIndex !== null;
|
||||
|
||||
return (
|
||||
<SchemaHighlightProvider>
|
||||
<div className="mt-8 mx-auto">
|
||||
<div className="transition-all duration-300 flex-1">
|
||||
<div className="flex items-stretch gap-2">
|
||||
{/* Slides List */}
|
||||
<SlidesList
|
||||
slides={slides}
|
||||
setSlides={setSlides}
|
||||
retrySlide={retrySlide}
|
||||
onSlideUpdate={onSlideUpdate}
|
||||
onOpenSchemaEditor={onOpenSchemaEditor}
|
||||
schemaEditorSlideIndex={schemaEditorSlideIndex}
|
||||
schemaPreviewData={schemaPreviewData}
|
||||
onClearSchemaPreview={onClearSchemaPreview}
|
||||
isSchemaEditorOpen={isSchemaEditorOpen}
|
||||
/>
|
||||
|
||||
{/* Schema Editor Panel (Right Sidebar) */}
|
||||
{isSchemaEditorOpen && schemaEditorSlide && (
|
||||
<div className="w-[520px] sticky top-20 self-start">
|
||||
<div className="bg-white rounded-2xl border border-gray-200 shadow-lg overflow-hidden">
|
||||
<SchemaEditorPanel
|
||||
slide={schemaEditorSlide}
|
||||
slideIndex={schemaEditorSlideIndex}
|
||||
onSave={onSchemaEditorSave}
|
||||
onCancel={onCloseSchemaEditor}
|
||||
onFillContent={onSchemaPreviewContent}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SchemaHighlightProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
/**
|
||||
* Constants for Custom Template Creation Flow
|
||||
*/
|
||||
|
||||
import { TemplateCreationStep } from "../types";
|
||||
|
||||
// Step configuration
|
||||
export const TEMPLATE_STEPS: Record<TemplateCreationStep, { title: string; description: string }> = {
|
||||
'file-upload': {
|
||||
title: 'Upload Template',
|
||||
description: 'Upload your PPTX file to begin',
|
||||
},
|
||||
'font-check': {
|
||||
title: 'Font Check',
|
||||
description: 'Checking fonts in your presentation',
|
||||
},
|
||||
'font-upload': {
|
||||
title: 'Upload Fonts',
|
||||
description: 'Upload missing fonts for accurate rendering',
|
||||
},
|
||||
'slides-preview': {
|
||||
title: 'Preview Slides',
|
||||
description: 'Review your slides before processing',
|
||||
},
|
||||
'template-creation': {
|
||||
title: 'Template Creation',
|
||||
description: 'Converting slides to reusable templates',
|
||||
},
|
||||
'completed': {
|
||||
title: 'Completed',
|
||||
description: 'Your template is ready to save',
|
||||
},
|
||||
};
|
||||
|
||||
// UI Configuration
|
||||
export const UI_CONFIG = {
|
||||
schemaEditorWidth: '520px',
|
||||
slideGridGap: '20px',
|
||||
maxContentWidth: '1400px',
|
||||
}
|
||||
// Highlights for benefits section
|
||||
export const HIGHLIGHTS_ITEMS = [
|
||||
{
|
||||
number: "1",
|
||||
title: "Time-consume",
|
||||
description: "Manual formatting and slide copying wastes hours every week",
|
||||
},
|
||||
{
|
||||
number: "2",
|
||||
title: "Expensive",
|
||||
description: "Design resources spent on repetitive tasks instead of innovation",
|
||||
},
|
||||
{
|
||||
number: "3",
|
||||
title: "Inconsistent",
|
||||
description: "AI generates unpredictable layouts that require constant cleanup",
|
||||
},
|
||||
]
|
||||
|
||||
// External scripts
|
||||
export const TAILWIND_CDN_URL = "https://cdn.tailwindcss.com";
|
||||
|
||||
|
||||
|
||||
export const FAQS = [
|
||||
{
|
||||
question: "What is Custom Template Creation?",
|
||||
answer: "Custom Template Creation is a feature that allows you to create custom templates for your presentations.",
|
||||
},
|
||||
{
|
||||
question: "How do I create a custom template?",
|
||||
answer: "You can create a custom template by uploading a PPTX file and then editing the template to your liking.",
|
||||
},
|
||||
{
|
||||
question: "How do I edit a custom template?",
|
||||
answer: "You can edit a custom template by uploading a PPTX file and then editing the template to your liking.",
|
||||
},
|
||||
{
|
||||
question: "How do I delete a custom template?",
|
||||
answer: "You can delete a custom template by uploading a PPTX file and then editing the template to your liking.",
|
||||
},
|
||||
{
|
||||
question: "How do I create a custom template?",
|
||||
answer: "You can create a custom template by uploading a PPTX file and then editing the template to your liking.",
|
||||
},
|
||||
{
|
||||
question: "How do I edit a custom template?",
|
||||
answer: "You can edit a custom template by uploading a PPTX file and then editing the template to your liking.",
|
||||
},
|
||||
{
|
||||
question: "How do I delete a custom template?",
|
||||
answer: "You can delete a custom template by uploading a PPTX file and then editing the template to your liking.",
|
||||
},
|
||||
]
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* Hooks Index
|
||||
* Central export point for all custom template hooks
|
||||
*/
|
||||
|
||||
export { useFileUpload } from "./useFileUpload";
|
||||
export { useTemplateCreation } from "./useTemplateCreation";
|
||||
export { useLayoutSaving } from "./useLayoutSaving";
|
||||
export { useCompiledLayout } from "./useCompiledLayout";
|
||||
export { useSlideEdit } from "./useSlideEdit";
|
||||
export { useSlideUndoRedo } from "./useSlideUndoRedo";
|
||||
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { useState, useEffect } from "react";
|
||||
|
||||
export const useAPIKeyCheck = () => {
|
||||
const [hasRequiredKey, setHasRequiredKey] = useState(false);
|
||||
const [isRequiredKeyLoading, setIsRequiredKeyLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/has-required-key")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setHasRequiredKey(Boolean(data.hasKey));
|
||||
setIsRequiredKeyLoading(false);
|
||||
})
|
||||
.catch(() => setIsRequiredKeyLoading(false));
|
||||
}, []);
|
||||
|
||||
return { hasRequiredKey, isRequiredKeyLoading };
|
||||
};
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { useMemo } from "react";
|
||||
import { compileCustomLayout, CompiledLayout } from "@/app/hooks/compileLayout";
|
||||
|
||||
/**
|
||||
* Hook to compile layout code once and memoize the result.
|
||||
* This prevents double compilation when both SlideContent and SchemaEditor need the compiled layout.
|
||||
*/
|
||||
export function useCompiledLayout(code: string | undefined): CompiledLayout | null {
|
||||
return useMemo(() => {
|
||||
if (!code) return null;
|
||||
try {
|
||||
return compileCustomLayout(code);
|
||||
} catch (error) {
|
||||
console.error("Error compiling layout:", error);
|
||||
return null;
|
||||
}
|
||||
}, [code]);
|
||||
}
|
||||
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { ProcessedSlide } from "../types";
|
||||
|
||||
export const useCustomLayout = () => {
|
||||
const [slides, setSlides] = useState<ProcessedSlide[]>([]);
|
||||
const [isLayoutSaved, setIsLayoutSaved] = useState(false);
|
||||
|
||||
// Warning before page unload
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault();
|
||||
return "You have unsaved changes. Are you sure you want to leave?";
|
||||
};
|
||||
if (slides.length > 0 && !isLayoutSaved) {
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
}
|
||||
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
}, [slides, isLayoutSaved]);
|
||||
|
||||
// Calculate progress
|
||||
const completedSlides = slides.filter(
|
||||
(slide) => slide.processed || slide.error
|
||||
).length;
|
||||
|
||||
return {
|
||||
slides,
|
||||
setSlides,
|
||||
completedSlides,
|
||||
isLayoutSaved,
|
||||
setIsLayoutSaved,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,167 +0,0 @@
|
|||
import { useState, useCallback, useRef } from "react";
|
||||
|
||||
export const useDrawingCanvas = () => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const slideDisplayRef = useRef<HTMLDivElement>(null);
|
||||
const [strokeWidth, setStrokeWidth] = useState(3);
|
||||
const [strokeColor, setStrokeColor] = useState("#000000");
|
||||
const [eraserMode, setEraserMode] = useState(false);
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const [canvasDimensions, setCanvasDimensions] = useState({
|
||||
width: 1280,
|
||||
height: 720,
|
||||
});
|
||||
const [didYourDraw, setDidYourDraw] = useState(false);
|
||||
|
||||
const getCanvasContext = () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return null;
|
||||
return canvas.getContext("2d");
|
||||
};
|
||||
|
||||
const getMousePos = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return { x: 0, y: 0 };
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top,
|
||||
};
|
||||
};
|
||||
|
||||
const getTouchPos = (e: React.TouchEvent<HTMLCanvasElement>) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return { x: 0, y: 0 };
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const touch = e.touches[0];
|
||||
return {
|
||||
x: touch.clientX - rect.left,
|
||||
y: touch.clientY - rect.top,
|
||||
};
|
||||
};
|
||||
|
||||
const startDrawing = useCallback(
|
||||
(pos: { x: number; y: number }) => {
|
||||
const ctx = getCanvasContext();
|
||||
if (!ctx) return;
|
||||
|
||||
setIsDrawing(true);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pos.x, pos.y);
|
||||
|
||||
if (eraserMode) {
|
||||
ctx.globalCompositeOperation = "destination-out";
|
||||
ctx.lineWidth = strokeWidth * 2;
|
||||
} else {
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
ctx.strokeStyle = strokeColor;
|
||||
ctx.lineWidth = strokeWidth;
|
||||
}
|
||||
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
},
|
||||
[eraserMode, strokeColor, strokeWidth]
|
||||
);
|
||||
|
||||
const draw = useCallback(
|
||||
(pos: { x: number; y: number }) => {
|
||||
if (!isDrawing) return;
|
||||
setDidYourDraw(true);
|
||||
|
||||
const ctx = getCanvasContext();
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.lineTo(pos.x, pos.y);
|
||||
ctx.stroke();
|
||||
},
|
||||
[isDrawing]
|
||||
);
|
||||
|
||||
const stopDrawing = useCallback(() => {
|
||||
setIsDrawing(false);
|
||||
}, []);
|
||||
|
||||
// Mouse events
|
||||
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
e.preventDefault();
|
||||
const pos = getMousePos(e);
|
||||
startDrawing(pos);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
e.preventDefault();
|
||||
const pos = getMousePos(e);
|
||||
draw(pos);
|
||||
};
|
||||
|
||||
const handleMouseUp = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
e.preventDefault();
|
||||
stopDrawing();
|
||||
};
|
||||
|
||||
// Touch events
|
||||
const handleTouchStart = (e: React.TouchEvent<HTMLCanvasElement>) => {
|
||||
e.preventDefault();
|
||||
const pos = getTouchPos(e);
|
||||
startDrawing(pos);
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent<HTMLCanvasElement>) => {
|
||||
e.preventDefault();
|
||||
const pos = getTouchPos(e);
|
||||
draw(pos);
|
||||
};
|
||||
|
||||
const handleTouchEnd = (e: React.TouchEvent<HTMLCanvasElement>) => {
|
||||
e.preventDefault();
|
||||
stopDrawing();
|
||||
};
|
||||
|
||||
const handleClearCanvas = () => {
|
||||
const canvas = canvasRef.current;
|
||||
setDidYourDraw(false);
|
||||
const ctx = getCanvasContext();
|
||||
if (!canvas || !ctx) return;
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
};
|
||||
|
||||
const handleEraserModeChange = (isEraser: boolean) => {
|
||||
setEraserMode(isEraser);
|
||||
};
|
||||
|
||||
const handleStrokeColorChange = (color: string) => {
|
||||
setStrokeColor(color);
|
||||
setEraserMode(false);
|
||||
};
|
||||
|
||||
const handleStrokeWidthChange = (width: number) => {
|
||||
setStrokeWidth(width);
|
||||
};
|
||||
|
||||
return {
|
||||
canvasRef,
|
||||
slideDisplayRef,
|
||||
strokeWidth,
|
||||
strokeColor,
|
||||
eraserMode,
|
||||
isDrawing,
|
||||
canvasDimensions,
|
||||
setCanvasDimensions,
|
||||
didYourDraw,
|
||||
setDidYourDraw,
|
||||
handleMouseDown,
|
||||
handleMouseMove,
|
||||
handleMouseUp,
|
||||
handleTouchStart,
|
||||
handleTouchMove,
|
||||
handleTouchEnd,
|
||||
handleClearCanvas,
|
||||
handleEraserModeChange,
|
||||
handleStrokeColorChange,
|
||||
handleStrokeWidthChange,
|
||||
};
|
||||
};
|
||||
|
|
@ -12,9 +12,8 @@ export const useFileUpload = () => {
|
|||
// Validate file type
|
||||
const lowerName = file.name.toLowerCase();
|
||||
const isPptx = lowerName.endsWith(".pptx");
|
||||
const isPdf = lowerName.endsWith(".pdf");
|
||||
if (!isPptx && !isPdf) {
|
||||
toast.error("Please select a valid PDF or PPTX file");
|
||||
if (!isPptx) {
|
||||
toast.error("Please select a valid PPTX file");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue