Merge pull request #466 from presenton/feat/ui-update-electron
feat/ui update electron
This commit is contained in:
commit
9349b2fd3d
156 changed files with 16695 additions and 2740 deletions
BIN
electron/.cache/export-runtime/export-Linux-X64.zip
Normal file
BIN
electron/.cache/export-runtime/export-Linux-X64.zip
Normal file
Binary file not shown.
4
electron/.gitignore
vendored
4
electron/.gitignore
vendored
|
|
@ -21,4 +21,6 @@ app_dist
|
|||
resources/fastapi
|
||||
resources/nextjs
|
||||
dist
|
||||
servers/fastapi/fastembed_cache/
|
||||
servers/fastapi/fastembed_cache/
|
||||
electron/.cache/
|
||||
electron/.cache/export-runtime/
|
||||
|
|
@ -211,6 +211,7 @@ async function installWindows(wc: WebContents): Promise<void> {
|
|||
const ps = `$p = Start-Process -FilePath "msiexec" -ArgumentList "/i", '${destEscaped}', "/qn", "/norestart" -Verb RunAs -Wait -PassThru; if ($p) { exit $p.ExitCode } else { exit 1 }`;
|
||||
const child = spawn("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
});
|
||||
child.stdout?.on("data", (d: Buffer) => sendLog(wc, "info", d.toString()));
|
||||
child.stderr?.on("data", (d: Buffer) => sendLog(wc, "warn", d.toString()));
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@ ipcMain.handle("startup:get-status", () => startupStatus);
|
|||
|
||||
app.commandLine.appendSwitch('gtk-version', '3');
|
||||
|
||||
// Work around Chromium/Electron GPU compositor issues that can cause
|
||||
// startup white screens on some Linux/driver combinations.
|
||||
app.disableHardwareAcceleration();
|
||||
|
||||
// Mitigate "Unable to move the cache: Access is denied" on Windows (Chromium disk cache).
|
||||
// Use explicit cache paths and remove stale old_* dirs that cause move failures.
|
||||
if (process.platform === "win32") {
|
||||
|
|
|
|||
|
|
@ -275,11 +275,18 @@ export async function isLibreOfficeInstalled(): Promise<LibreOfficeCheckResult>
|
|||
// --- Step 1: check well-known paths synchronously (no exec overhead) ---
|
||||
for (const candidate of getCandidatePaths()) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
// On Windows, avoid probing with "--version" because some LibreOffice
|
||||
// builds open a transient console window for this command.
|
||||
if (process.platform === "win32") {
|
||||
return { installed: true, path: candidate };
|
||||
}
|
||||
|
||||
// Binary found at a known location – try to get the version string.
|
||||
try {
|
||||
const quoted = `"${candidate}"`;
|
||||
const { stdout } = await execAsync(`${quoted} --version`, {
|
||||
timeout: 8_000,
|
||||
windowsHide: (process.platform as string) === "win32",
|
||||
});
|
||||
return { installed: true, version: stdout.trim(), path: candidate };
|
||||
} catch {
|
||||
|
|
@ -290,9 +297,30 @@ export async function isLibreOfficeInstalled(): Promise<LibreOfficeCheckResult>
|
|||
}
|
||||
|
||||
// --- Step 2: try the PATH-based command ---
|
||||
if (process.platform === "win32") {
|
||||
try {
|
||||
// Use "where" for PATH detection without launching LibreOffice itself.
|
||||
const { stdout } = await execAsync("where soffice.exe", {
|
||||
timeout: 8_000,
|
||||
windowsHide: true,
|
||||
});
|
||||
const firstPath = stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0);
|
||||
if (firstPath) {
|
||||
return { installed: true, path: firstPath };
|
||||
}
|
||||
} catch {
|
||||
// Keep behavior: if PATH lookup fails, report not installed.
|
||||
}
|
||||
return { installed: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execAsync("soffice --version", {
|
||||
timeout: 8_000,
|
||||
windowsHide: (process.platform as string) === "win32",
|
||||
});
|
||||
// Found via PATH – record the bare command name as the path so callers
|
||||
// can pass it directly to subprocess invocations.
|
||||
|
|
@ -398,4 +426,4 @@ export async function checkLibreOfficeBeforeWindow(
|
|||
|
||||
// Always proceed – never block the app
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -40,8 +40,9 @@ export async function startFastApiServer(
|
|||
args,
|
||||
{
|
||||
cwd: directory,
|
||||
stdio: ["inherit", "pipe", "pipe"],
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: { ...process.env, ...env },
|
||||
windowsHide: process.platform === "win32" && !isDev,
|
||||
}
|
||||
);
|
||||
fastApiProcess.stdout.on("data", (data: any) => {
|
||||
|
|
@ -70,13 +71,12 @@ export async function startNextJsServer(
|
|||
let nextjsProcess;
|
||||
|
||||
if (isDev) {
|
||||
// Start NextJS development server
|
||||
nextjsProcess = spawn(
|
||||
"npm",
|
||||
["run", "dev", "--", "-p", port.toString()],
|
||||
{
|
||||
cwd: directory,
|
||||
stdio: ["inherit", "pipe", "pipe"],
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: { ...process.env, ...env },
|
||||
}
|
||||
);
|
||||
|
|
@ -96,6 +96,13 @@ export async function startNextJsServer(
|
|||
safeNextLog(data);
|
||||
console.error(`NextJS: ${data}`);
|
||||
});
|
||||
nextjsProcess.on("error", (err: Error) => {
|
||||
safeNextLog(`Spawn error: ${err.message}\n`);
|
||||
console.error(`NextJS spawn error: ${err.message}`);
|
||||
});
|
||||
nextjsProcess.on("exit", (code: number | null, signal: string | null) => {
|
||||
console.error(`NextJS process exited unexpectedly: code=${code}, signal=${signal}`);
|
||||
});
|
||||
} else {
|
||||
// Start NextJS build server
|
||||
nextjsProcess = await startNextjsBuildServer(directory, port);
|
||||
|
|
@ -124,19 +131,25 @@ function startNextjsBuildServer(directory: string, port: number): Promise<http.S
|
|||
}
|
||||
|
||||
|
||||
async function waitForServer(url: string, timeout = 30000): Promise<void> {
|
||||
async function waitForServer(url: string, timeout = 120000): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
http.get(url, (res) => {
|
||||
if (res.statusCode === 200 || res.statusCode === 304) {
|
||||
const req = http.get(url, (res) => {
|
||||
res.resume();
|
||||
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 500) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Unexpected status code: ${res.statusCode}`));
|
||||
}
|
||||
}).on('error', reject);
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.setTimeout(5000, () => {
|
||||
req.destroy();
|
||||
reject(new Error('Request timed out'));
|
||||
});
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
|
|
|
|||
1035
electron/resources/export/index.js
Normal file
1035
electron/resources/export/index.js
Normal file
File diff suppressed because one or more lines are too long
BIN
electron/resources/export/py/convert-linux-x64
Executable file
BIN
electron/resources/export/py/convert-linux-x64
Executable file
Binary file not shown.
|
|
@ -0,0 +1,31 @@
|
|||
"""add theme column to presentations
|
||||
|
||||
Revision ID: 82abdbc476a7
|
||||
Revises: f42ad4074449
|
||||
Create Date: 2026-03-24 12:42:46.220359
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '82abdbc476a7'
|
||||
down_revision: Union[str, None] = 'f42ad4074449'
|
||||
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! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
"""add theme column to presentations
|
||||
|
||||
Revision ID: f42ad4074449
|
||||
Revises: 00b3c27a13bc
|
||||
Create Date: 2026-03-24 12:42:32.369006
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'f42ad4074449'
|
||||
down_revision: Union[str, None] = '00b3c27a13bc'
|
||||
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.add_column('presentations', sa.Column('theme', sa.JSON(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('presentations', 'theme')
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -39,6 +39,10 @@ class FontListResponse(BaseModel):
|
|||
message: Optional[str] = None
|
||||
|
||||
|
||||
class UploadedFontsResponse(BaseModel):
|
||||
fonts: List[dict]
|
||||
|
||||
|
||||
def get_fonts_directory() -> str:
|
||||
"""Get the fonts directory path, create if it doesn't exist"""
|
||||
app_data_dir = get_app_data_directory_env() or "/tmp/presenton"
|
||||
|
|
@ -244,6 +248,45 @@ async def list_fonts():
|
|||
)
|
||||
|
||||
|
||||
@FONTS_ROUTER.get("/uploaded", response_model=UploadedFontsResponse)
|
||||
async def get_uploaded_fonts():
|
||||
"""
|
||||
Compatibility endpoint used by frontend theme flow.
|
||||
Returns uploaded fonts as a compact list with id/name/url fields.
|
||||
"""
|
||||
try:
|
||||
fonts_dir = get_fonts_directory()
|
||||
fonts = []
|
||||
|
||||
if os.path.exists(fonts_dir):
|
||||
for filename in os.listdir(fonts_dir):
|
||||
file_path = os.path.join(fonts_dir, filename)
|
||||
if not os.path.isfile(file_path):
|
||||
continue
|
||||
|
||||
file_ext = os.path.splitext(filename)[1].lower()
|
||||
if file_ext not in SUPPORTED_FONT_EXTENSIONS:
|
||||
continue
|
||||
|
||||
font_name = extract_font_name_from_file(file_path)
|
||||
fonts.append(
|
||||
{
|
||||
"id": filename,
|
||||
"name": font_name,
|
||||
"url": f"/app_data/fonts/{filename}",
|
||||
}
|
||||
)
|
||||
|
||||
return UploadedFontsResponse(fonts=fonts)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting uploaded fonts: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Error getting uploaded fonts: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@FONTS_ROUTER.delete("/delete/{filename}")
|
||||
async def delete_font(filename: str):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -26,7 +26,23 @@ def _get_soffice_binary() -> str:
|
|||
environment variable. Falling back to the bare ``"soffice"`` command keeps
|
||||
Docker / server deployments working unchanged.
|
||||
"""
|
||||
return os.environ.get("SOFFICE_PATH") or "soffice"
|
||||
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]:
|
||||
"""Return subprocess kwargs that suppress Windows console windows."""
|
||||
if os.name != "nt":
|
||||
return {}
|
||||
|
||||
startupinfo = subprocess.STARTUPINFO()
|
||||
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
||||
return {
|
||||
"creationflags": getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
"startupinfo": startupinfo,
|
||||
}
|
||||
|
||||
|
||||
PPTX_SLIDES_ROUTER = APIRouter(prefix="/pptx-slides", tags=["PPTX Slides"])
|
||||
|
|
@ -596,6 +612,7 @@ async def _convert_pptx_to_pdf(pptx_path: str, temp_dir: str) -> str:
|
|||
text=True,
|
||||
timeout=500,
|
||||
env=env,
|
||||
**_windows_hidden_subprocess_kwargs(),
|
||||
)
|
||||
|
||||
print(f"LibreOffice PDF conversion output: {result.stdout}")
|
||||
|
|
|
|||
|
|
@ -366,6 +366,7 @@ async def update_presentation(
|
|||
id: Annotated[uuid.UUID, Body()],
|
||||
n_slides: Annotated[Optional[int], Body()] = None,
|
||||
title: Annotated[Optional[str], Body()] = None,
|
||||
theme: Annotated[Optional[dict], Body()] = None,
|
||||
slides: Annotated[Optional[List[SlideModel]], Body()] = None,
|
||||
sql_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
|
|
@ -378,10 +379,11 @@ async def update_presentation(
|
|||
presentation_update_dict["n_slides"] = n_slides
|
||||
if title:
|
||||
presentation_update_dict["title"] = title
|
||||
if theme:
|
||||
presentation_update_dict["theme"] = theme
|
||||
|
||||
if n_slides or title:
|
||||
if presentation_update_dict:
|
||||
presentation.sqlmodel_update(presentation_update_dict)
|
||||
|
||||
if slides:
|
||||
# Just to make sure id is UUID
|
||||
for slide in slides:
|
||||
|
|
|
|||
181
electron/servers/fastapi/api/v1/ppt/endpoints/theme.py
Normal file
181
electron/servers/fastapi/api/v1/ppt/endpoints/theme.py
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import copy
|
||||
import uuid
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import select
|
||||
|
||||
from models.sql.image_asset import ImageAsset
|
||||
from models.sql.key_value import KeyValueSqlModel
|
||||
from services.database import get_async_session
|
||||
|
||||
THEMES_ROUTER = APIRouter(prefix="/themes", tags=["Themes"])
|
||||
THEMES_STORAGE_KEY = "presentation_custom_themes"
|
||||
|
||||
|
||||
class ThemeRequest(BaseModel):
|
||||
name: str
|
||||
description: str
|
||||
company_name: Optional[str] = None
|
||||
logo: Optional[str] = None
|
||||
logo_url: Optional[str] = None
|
||||
data: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ThemeUpdateRequest(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
company_name: Optional[str] = None
|
||||
logo: Optional[str] = None
|
||||
logo_url: Optional[str] = None
|
||||
data: Optional[dict[str, Any]] = None
|
||||
|
||||
|
||||
class ThemeResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
user: str
|
||||
logo: Optional[str] = None
|
||||
logo_url: Optional[str] = None
|
||||
company_name: Optional[str] = None
|
||||
data: dict[str, Any]
|
||||
|
||||
|
||||
def _normalize_theme(theme: dict[str, Any]) -> ThemeResponse:
|
||||
return ThemeResponse(
|
||||
id=str(theme["id"]),
|
||||
name=theme["name"],
|
||||
description=theme["description"],
|
||||
user=theme.get("user", "local"),
|
||||
logo=theme.get("logo"),
|
||||
logo_url=theme.get("logo_url"),
|
||||
company_name=theme.get("company_name"),
|
||||
data=theme.get("data", {}),
|
||||
)
|
||||
|
||||
|
||||
async def _get_themes_row(sql_session: AsyncSession) -> Optional[KeyValueSqlModel]:
|
||||
return await sql_session.scalar(
|
||||
select(KeyValueSqlModel).where(KeyValueSqlModel.key == THEMES_STORAGE_KEY)
|
||||
)
|
||||
|
||||
|
||||
def _read_themes_from_row(row: Optional[KeyValueSqlModel]) -> list[dict[str, Any]]:
|
||||
if not row:
|
||||
return []
|
||||
value = row.value if isinstance(row.value, dict) else {}
|
||||
themes = value.get("themes", [])
|
||||
if not isinstance(themes, list):
|
||||
return []
|
||||
return copy.deepcopy(themes)
|
||||
|
||||
|
||||
async def _resolve_logo_url(
|
||||
sql_session: AsyncSession, logo: Optional[str]
|
||||
) -> Optional[str]:
|
||||
if not logo:
|
||||
return None
|
||||
try:
|
||||
logo_uuid = uuid.UUID(str(logo))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail="Invalid logo id") from exc
|
||||
|
||||
image_asset = await sql_session.get(ImageAsset, logo_uuid)
|
||||
if not image_asset:
|
||||
raise HTTPException(status_code=404, detail="Logo not found")
|
||||
return image_asset.path
|
||||
|
||||
|
||||
@THEMES_ROUTER.get("/default", response_model=List[dict[str, Any]])
|
||||
async def get_default_themes():
|
||||
# Built-in themes are provided by Next.js constants in this project.
|
||||
return []
|
||||
|
||||
|
||||
@THEMES_ROUTER.get("/all", response_model=List[ThemeResponse])
|
||||
async def get_themes(sql_session: AsyncSession = Depends(get_async_session)):
|
||||
row = await _get_themes_row(sql_session)
|
||||
themes = _read_themes_from_row(row)
|
||||
return [_normalize_theme(theme) for theme in themes]
|
||||
|
||||
|
||||
@THEMES_ROUTER.post("/create", response_model=ThemeResponse)
|
||||
async def create_theme(
|
||||
payload: ThemeRequest, sql_session: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
row = await _get_themes_row(sql_session)
|
||||
themes = _read_themes_from_row(row)
|
||||
logo_url = payload.logo_url or await _resolve_logo_url(sql_session, payload.logo)
|
||||
|
||||
theme = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"name": payload.name,
|
||||
"description": payload.description,
|
||||
"user": "local",
|
||||
"logo": payload.logo,
|
||||
"logo_url": logo_url,
|
||||
"company_name": payload.company_name,
|
||||
"data": payload.data,
|
||||
}
|
||||
themes.append(theme)
|
||||
|
||||
if row:
|
||||
row.value = {"themes": themes}
|
||||
sql_session.add(row)
|
||||
else:
|
||||
sql_session.add(KeyValueSqlModel(key=THEMES_STORAGE_KEY, value={"themes": themes}))
|
||||
|
||||
await sql_session.commit()
|
||||
return _normalize_theme(theme)
|
||||
|
||||
|
||||
@THEMES_ROUTER.patch("/update/{theme_id}", response_model=ThemeResponse)
|
||||
async def update_theme(
|
||||
theme_id: str,
|
||||
payload: ThemeUpdateRequest,
|
||||
sql_session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
row = await _get_themes_row(sql_session)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Theme not found")
|
||||
|
||||
themes = _read_themes_from_row(row)
|
||||
theme = next((item for item in themes if item.get("id") == theme_id), None)
|
||||
if not theme:
|
||||
raise HTTPException(status_code=404, detail="Theme not found")
|
||||
|
||||
if payload.name is not None:
|
||||
theme["name"] = payload.name
|
||||
if payload.description is not None:
|
||||
theme["description"] = payload.description
|
||||
if payload.company_name is not None:
|
||||
theme["company_name"] = payload.company_name
|
||||
if payload.data is not None:
|
||||
theme["data"] = payload.data
|
||||
if payload.logo is not None:
|
||||
theme["logo"] = payload.logo
|
||||
theme["logo_url"] = await _resolve_logo_url(sql_session, payload.logo)
|
||||
elif payload.logo_url is not None:
|
||||
theme["logo_url"] = payload.logo_url
|
||||
|
||||
row.value = {"themes": themes}
|
||||
sql_session.add(row)
|
||||
await sql_session.commit()
|
||||
return _normalize_theme(theme)
|
||||
|
||||
|
||||
@THEMES_ROUTER.delete("/delete/{theme_id}", status_code=204)
|
||||
async def delete_theme(
|
||||
theme_id: str, sql_session: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
row = await _get_themes_row(sql_session)
|
||||
if not row:
|
||||
return
|
||||
|
||||
themes = _read_themes_from_row(row)
|
||||
row.value = {"themes": [theme for theme in themes if theme.get("id") != theme_id]}
|
||||
sql_session.add(row)
|
||||
await sql_session.commit()
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
from models.theme_data import ThemeData
|
||||
from utils.theme_utils import (
|
||||
IS_DARK_BELOW,
|
||||
generate_color_palette,
|
||||
get_lightness_key_at_distance,
|
||||
)
|
||||
|
||||
THEME_ROUTER = APIRouter(prefix="/theme", tags=["V3 Theme"])
|
||||
|
||||
|
||||
class GenerateThemeRequestV3(BaseModel):
|
||||
primary: Optional[str] = None
|
||||
background: Optional[str] = None
|
||||
accent_1: Optional[str] = None
|
||||
accent_2: Optional[str] = None
|
||||
text_1: Optional[str] = None
|
||||
text_2: Optional[str] = None
|
||||
|
||||
|
||||
@THEME_ROUTER.post("/generate", response_model=ThemeData)
|
||||
async def generate_theme_v3(request: GenerateThemeRequestV3) -> ThemeData:
|
||||
color_palette = generate_color_palette(
|
||||
request.primary,
|
||||
request.background,
|
||||
request.accent_1,
|
||||
request.accent_2,
|
||||
request.text_1,
|
||||
request.text_2,
|
||||
)
|
||||
|
||||
is_dark_theme = color_palette.background_lightness < IS_DARK_BELOW
|
||||
graph_colors = list(color_palette.primary_variations.values())
|
||||
|
||||
if not is_dark_theme:
|
||||
graph_colors.reverse()
|
||||
|
||||
theme_data = ThemeData(
|
||||
primary=color_palette.primary,
|
||||
background=color_palette.background,
|
||||
card=color_palette.background_variations[
|
||||
get_lightness_key_at_distance(
|
||||
color_palette.background_lightness,
|
||||
min_distance=1,
|
||||
max_distance=1,
|
||||
prefer_dark=not is_dark_theme,
|
||||
)
|
||||
],
|
||||
stroke=color_palette.background_variations[
|
||||
get_lightness_key_at_distance(
|
||||
color_palette.background_lightness,
|
||||
min_distance=2,
|
||||
max_distance=2,
|
||||
prefer_dark=not is_dark_theme,
|
||||
)
|
||||
],
|
||||
background_text=color_palette.text_1,
|
||||
primary_text=color_palette.text_2,
|
||||
graph_0=graph_colors[0],
|
||||
graph_1=graph_colors[1],
|
||||
graph_2=graph_colors[2],
|
||||
graph_3=graph_colors[3],
|
||||
graph_4=graph_colors[4],
|
||||
graph_5=graph_colors[5],
|
||||
graph_6=graph_colors[6],
|
||||
graph_7=graph_colors[7],
|
||||
graph_8=graph_colors[8],
|
||||
graph_9=graph_colors[9],
|
||||
)
|
||||
return theme_data
|
||||
|
||||
|
|
@ -16,6 +16,8 @@ from api.v1.ppt.endpoints.outlines import OUTLINES_ROUTER
|
|||
from api.v1.ppt.endpoints.slide import SLIDE_ROUTER
|
||||
from api.v1.ppt.endpoints.codex_auth import CODEX_AUTH_ROUTER
|
||||
from api.v1.ppt.endpoints.pptx_slides import PPTX_FONTS_ROUTER
|
||||
from api.v1.ppt.endpoints.theme import THEMES_ROUTER
|
||||
from api.v1.ppt.endpoints.theme_generate import THEME_ROUTER
|
||||
|
||||
|
||||
API_V1_PPT_ROUTER = APIRouter(prefix="/api/v1/ppt")
|
||||
|
|
@ -32,6 +34,8 @@ API_V1_PPT_ROUTER.include_router(HTML_EDIT_ROUTER)
|
|||
API_V1_PPT_ROUTER.include_router(LAYOUT_MANAGEMENT_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(IMAGES_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(ICONS_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(THEMES_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(THEME_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(OLLAMA_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(PDF_SLIDES_ROUTER)
|
||||
API_V1_PPT_ROUTER.include_router(OPENAI_ROUTER)
|
||||
|
|
|
|||
|
|
@ -18,3 +18,4 @@ class PresentationWithSlides(BaseModel):
|
|||
tone: Optional[str] = None
|
||||
verbosity: Optional[str] = None
|
||||
slides: List[SlideModel]
|
||||
theme: Optional[dict] = None
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ class PresentationModel(SQLModel, table=True):
|
|||
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(
|
||||
|
|
|
|||
41
electron/servers/fastapi/models/theme_data.py
Normal file
41
electron/servers/fastapi/models/theme_data.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
from pydantic import BaseModel
|
||||
from typing import Dict
|
||||
|
||||
|
||||
class ThemeData(BaseModel):
|
||||
primary: str
|
||||
background: str
|
||||
card: str
|
||||
stroke: str
|
||||
background_text: str
|
||||
primary_text: str
|
||||
graph_0: str
|
||||
graph_1: str
|
||||
graph_2: str
|
||||
graph_3: str
|
||||
graph_4: str
|
||||
graph_5: str
|
||||
graph_6: str
|
||||
graph_7: str
|
||||
graph_8: str
|
||||
graph_9: str
|
||||
|
||||
|
||||
class GeneratedColorPalette(BaseModel):
|
||||
primary: str
|
||||
background: str
|
||||
accent_1: str
|
||||
accent_2: str
|
||||
text_1: str
|
||||
text_2: str
|
||||
primary_variations: Dict[str, str]
|
||||
background_variations: Dict[str, str]
|
||||
accent_1_variations: Dict[str, str]
|
||||
accent_2_variations: Dict[str, str]
|
||||
primary_lightness: float
|
||||
background_lightness: float
|
||||
accent_1_lightness: float
|
||||
accent_2_lightness: float
|
||||
text_1_lightness: float
|
||||
text_2_lightness: float
|
||||
|
||||
Binary file not shown.
|
|
@ -72,7 +72,7 @@ exe = EXE(
|
|||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
console=True,
|
||||
console=False,
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
|
|
|
|||
357
electron/servers/fastapi/utils/theme_utils.py
Normal file
357
electron/servers/fastapi/utils/theme_utils.py
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Optional
|
||||
|
||||
from models.theme_data import GeneratedColorPalette
|
||||
|
||||
IS_DARK_BELOW = 0.65
|
||||
BACKGROUND_RETRIES = 200
|
||||
TEXT_RETRIES = 200
|
||||
|
||||
LIGHTNESS_VALUES: Dict[str, float] = {
|
||||
"50": 0.97,
|
||||
"100": 0.93,
|
||||
"200": 0.86,
|
||||
"300": 0.78,
|
||||
"400": 0.70,
|
||||
"500": 0.62,
|
||||
"600": 0.54,
|
||||
"700": 0.46,
|
||||
"800": 0.38,
|
||||
"900": 0.30,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Oklch:
|
||||
l: float # noqa: E741
|
||||
c: float
|
||||
h: float
|
||||
|
||||
|
||||
def _clamp(value: float, min_value: float = 0.0, max_value: float = 1.0) -> float:
|
||||
return max(min_value, min(max_value, value))
|
||||
|
||||
|
||||
def _get_random_value(min_value: float, max_value: float) -> float:
|
||||
return min_value + random.random() * (max_value - min_value)
|
||||
|
||||
|
||||
def _get_random_value_at_min_max_distance(
|
||||
base_value: float,
|
||||
min_value: float,
|
||||
max_value: float,
|
||||
min_distance: Optional[float] = None,
|
||||
max_distance: Optional[float] = None,
|
||||
) -> float:
|
||||
normalized_min_distance = max(0.0, min_distance or 0.0)
|
||||
normalized_max_distance = max_distance if max_distance is not None else math.inf
|
||||
min_dist = min(normalized_min_distance, normalized_max_distance)
|
||||
max_dist = max(normalized_min_distance, normalized_max_distance)
|
||||
|
||||
lower_start = max(min_value, base_value - max_dist)
|
||||
lower_end = min(max_value, base_value - min_dist)
|
||||
upper_start = max(min_value, base_value + min_dist)
|
||||
upper_end = min(max_value, base_value + max_dist)
|
||||
|
||||
lower_size = max(0.0, lower_end - lower_start)
|
||||
upper_size = max(0.0, upper_end - upper_start)
|
||||
total_size = lower_size + upper_size
|
||||
|
||||
if total_size <= 0:
|
||||
return _get_random_value(min_value, max_value)
|
||||
|
||||
picker = random.random() * total_size
|
||||
if picker < lower_size:
|
||||
return _get_random_value(lower_start, lower_end)
|
||||
|
||||
return _get_random_value(upper_start, upper_end)
|
||||
|
||||
|
||||
def _srgb_to_linear(channel: float) -> float:
|
||||
if channel <= 0.04045:
|
||||
return channel / 12.92
|
||||
return ((channel + 0.055) / 1.055) ** 2.4
|
||||
|
||||
|
||||
def _linear_to_srgb(channel: float) -> float:
|
||||
if channel <= 0.0031308:
|
||||
return 12.92 * channel
|
||||
return 1.055 * (channel ** (1 / 2.4)) - 0.055
|
||||
|
||||
|
||||
def _oklch_to_srgb(color: Oklch) -> tuple[float, float, float]:
|
||||
hue_rad = math.radians(color.h % 360)
|
||||
a = color.c * math.cos(hue_rad)
|
||||
b = color.c * math.sin(hue_rad)
|
||||
|
||||
l_ = (color.l + 0.3963377774 * a + 0.2158037573 * b) ** 3
|
||||
m_ = (color.l - 0.1055613458 * a - 0.0638541728 * b) ** 3
|
||||
s_ = (color.l - 0.0894841775 * a - 1.2914855480 * b) ** 3
|
||||
|
||||
r = 4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_
|
||||
g = -1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_
|
||||
b = -0.0041960863 * l_ - 0.7034186147 * m_ + 1.7076147010 * s_
|
||||
|
||||
return (
|
||||
_clamp(_linear_to_srgb(r)),
|
||||
_clamp(_linear_to_srgb(g)),
|
||||
_clamp(_linear_to_srgb(b)),
|
||||
)
|
||||
|
||||
|
||||
def _srgb_to_oklch(r: float, g: float, b: float) -> Oklch:
|
||||
r_lin = _srgb_to_linear(r)
|
||||
g_lin = _srgb_to_linear(g)
|
||||
b_lin = _srgb_to_linear(b)
|
||||
|
||||
l_ = 0.4122214708 * r_lin + 0.5363325363 * g_lin + 0.0514459929 * b_lin
|
||||
m_ = 0.2119034982 * r_lin + 0.6806995451 * g_lin + 0.1073969566 * b_lin
|
||||
s_ = 0.0883024619 * r_lin + 0.2817188376 * g_lin + 0.6299787005 * b_lin
|
||||
|
||||
l_cbrt = math.copysign(abs(l_) ** (1 / 3), l_)
|
||||
m_cbrt = math.copysign(abs(m_) ** (1 / 3), m_)
|
||||
s_cbrt = math.copysign(abs(s_) ** (1 / 3), s_)
|
||||
|
||||
lightness = 0.2104542553 * l_cbrt + 0.7936177850 * m_cbrt - 0.0040720468 * s_cbrt
|
||||
a = 1.9779984951 * l_cbrt - 2.4285922050 * m_cbrt + 0.4505937099 * s_cbrt
|
||||
b = 0.0259040371 * l_cbrt + 0.7827717662 * m_cbrt - 0.8086757660 * s_cbrt
|
||||
|
||||
chroma = math.hypot(a, b)
|
||||
hue = math.degrees(math.atan2(b, a)) % 360
|
||||
|
||||
return Oklch(l=lightness, c=chroma, h=hue)
|
||||
|
||||
|
||||
def _hex_to_oklch(hex_value: str) -> Oklch:
|
||||
hex_value = hex_value.strip().lstrip("#")
|
||||
if len(hex_value) != 6:
|
||||
raise ValueError(f"Invalid hex color: {hex_value!r}")
|
||||
r = int(hex_value[0:2], 16) / 255.0
|
||||
g = int(hex_value[2:4], 16) / 255.0
|
||||
b = int(hex_value[4:6], 16) / 255.0
|
||||
return _srgb_to_oklch(r, g, b)
|
||||
|
||||
|
||||
def _format_hex(color: Oklch) -> str:
|
||||
r, g, b = _oklch_to_srgb(color)
|
||||
return "#{:02x}{:02x}{:02x}".format(
|
||||
int(round(r * 255)),
|
||||
int(round(g * 255)),
|
||||
int(round(b * 255)),
|
||||
)
|
||||
|
||||
|
||||
def _relative_luminance(color: Oklch) -> float:
|
||||
r, g, b = _oklch_to_srgb(color)
|
||||
r_lin = _srgb_to_linear(r)
|
||||
g_lin = _srgb_to_linear(g)
|
||||
b_lin = _srgb_to_linear(b)
|
||||
return 0.2126 * r_lin + 0.7152 * g_lin + 0.0722 * b_lin
|
||||
|
||||
|
||||
def _wcag_contrast(a: Oklch, b: Oklch) -> float:
|
||||
l1 = _relative_luminance(a)
|
||||
l2 = _relative_luminance(b)
|
||||
lighter = max(l1, l2)
|
||||
darker = min(l1, l2)
|
||||
return (lighter + 0.05) / (darker + 0.05)
|
||||
|
||||
|
||||
def _get_color_for_all_lightness_values(base_color: Oklch) -> Dict[str, str]:
|
||||
colors: Dict[str, str] = {}
|
||||
for name, value in LIGHTNESS_VALUES.items():
|
||||
color = Oklch(l=value, c=base_color.c, h=base_color.h)
|
||||
colors[name] = _format_hex(color)
|
||||
return colors
|
||||
|
||||
|
||||
def _generate_primary_color() -> Oklch:
|
||||
lightness = _get_random_value(0.0, 1.0)
|
||||
chroma = _get_random_value(0.0, 0.4)
|
||||
hue = _get_random_value(0.0, 360.0)
|
||||
return Oklch(l=lightness, c=chroma, h=hue)
|
||||
|
||||
|
||||
def _generate_background_color(base_color: Oklch) -> Oklch:
|
||||
for _ in range(BACKGROUND_RETRIES):
|
||||
lightness = _get_random_value(0.0, 1.0)
|
||||
chroma = _get_random_value(0.0, 0.4)
|
||||
hue = _get_random_value(0.0, 360.0)
|
||||
color = Oklch(l=lightness, c=chroma, h=hue)
|
||||
if _wcag_contrast(color, base_color) >= 6:
|
||||
return color
|
||||
|
||||
if base_color.l < IS_DARK_BELOW:
|
||||
return Oklch(l=1.0, c=0.0, h=0.0)
|
||||
return Oklch(l=0.0, c=0.0, h=0.0)
|
||||
|
||||
|
||||
def _generate_accent_color(base_color: Oklch, n: int) -> Oklch:
|
||||
lightness = _get_random_value_at_min_max_distance(base_color.l, 0.0, 1.0, 0.0, 0.1)
|
||||
chroma = _get_random_value_at_min_max_distance(base_color.c, 0.0, 0.4, 0.0, 0.4)
|
||||
hue = _get_random_value_at_min_max_distance(
|
||||
base_color.h if base_color.h is not None else 0.0,
|
||||
0.0,
|
||||
360.0,
|
||||
n * 90.0,
|
||||
(n + 1) * 90.0,
|
||||
)
|
||||
return Oklch(l=lightness, c=chroma, h=hue)
|
||||
|
||||
|
||||
def _generate_text_color(base_color: Oklch, text_type: str) -> Oklch:
|
||||
is_base_dark = base_color.l < IS_DARK_BELOW
|
||||
|
||||
for _ in range(TEXT_RETRIES):
|
||||
if text_type == "text_1":
|
||||
lightness = (
|
||||
_get_random_value(0.8, 1.0)
|
||||
if is_base_dark
|
||||
else _get_random_value(0.0, 0.2)
|
||||
)
|
||||
chroma = _get_random_value(0.0, 0.02)
|
||||
elif text_type == "text_2":
|
||||
lightness = (
|
||||
_get_random_value(0.8, 1.0)
|
||||
if is_base_dark
|
||||
else _get_random_value(0.0, 0.2)
|
||||
)
|
||||
chroma = _get_random_value(0.0, 0.04)
|
||||
else:
|
||||
raise ValueError(f"Invalid text type: {text_type}")
|
||||
|
||||
hue = _get_random_value(0.0, 360.0)
|
||||
color = Oklch(l=lightness, c=chroma, h=hue)
|
||||
|
||||
min_contrast = 6.0
|
||||
max_contrast = None
|
||||
contrast = _wcag_contrast(color, base_color)
|
||||
|
||||
if contrast >= min_contrast and (
|
||||
max_contrast is None or contrast <= max_contrast
|
||||
):
|
||||
return color
|
||||
|
||||
if base_color.l < IS_DARK_BELOW:
|
||||
return Oklch(l=1.0 if text_type == "text_1" else 0.9, c=0.0, h=0.0)
|
||||
return Oklch(l=0.0 if text_type == "text_1" else 0.1, c=0.0, h=0.0)
|
||||
|
||||
|
||||
def get_lightness_key_at_distance(
|
||||
value: float,
|
||||
min_distance: Optional[int] = None,
|
||||
max_distance: Optional[int] = None,
|
||||
prefer_dark: Optional[bool] = None,
|
||||
) -> str:
|
||||
items = sorted(LIGHTNESS_VALUES.items(), key=lambda item: item[1])
|
||||
|
||||
nearest_index = 0
|
||||
nearest_distance = abs(items[0][1] - value)
|
||||
for index, (_, lightness) in enumerate(items[1:], start=1):
|
||||
distance = abs(lightness - value)
|
||||
if distance < nearest_distance or (
|
||||
distance == nearest_distance and lightness < items[nearest_index][1]
|
||||
):
|
||||
nearest_index = index
|
||||
nearest_distance = distance
|
||||
|
||||
normalized_min = max(0, min_distance or 0)
|
||||
normalized_max = max_distance if max_distance is not None else normalized_min
|
||||
if normalized_max < normalized_min:
|
||||
normalized_min, normalized_max = normalized_max, normalized_min
|
||||
|
||||
candidate_indices = []
|
||||
for distance in range(normalized_min, normalized_max + 1):
|
||||
lower_index = nearest_index - distance
|
||||
upper_index = nearest_index + distance
|
||||
if 0 <= lower_index < len(items):
|
||||
candidate_indices.append(lower_index)
|
||||
if upper_index != lower_index and 0 <= upper_index < len(items):
|
||||
candidate_indices.append(upper_index)
|
||||
|
||||
if not candidate_indices:
|
||||
return items[nearest_index][0]
|
||||
|
||||
if prefer_dark is True:
|
||||
darker_candidates = [idx for idx in candidate_indices if idx <= nearest_index]
|
||||
if darker_candidates:
|
||||
return items[min(darker_candidates)][0]
|
||||
return items[min(candidate_indices)][0]
|
||||
if prefer_dark is False:
|
||||
lighter_candidates = [idx for idx in candidate_indices if idx >= nearest_index]
|
||||
if lighter_candidates:
|
||||
return items[max(lighter_candidates)][0]
|
||||
return items[max(candidate_indices)][0]
|
||||
|
||||
def distance_to_value(idx: int) -> float:
|
||||
return abs(items[idx][1] - value)
|
||||
|
||||
closest_index = min(candidate_indices, key=lambda idx: (distance_to_value(idx), idx))
|
||||
return items[closest_index][0]
|
||||
|
||||
|
||||
def generate_color_palette(
|
||||
provided_primary: Optional[str] = None,
|
||||
provided_background: Optional[str] = None,
|
||||
provided_accent_1: Optional[str] = None,
|
||||
provided_accent_2: Optional[str] = None,
|
||||
provided_text_1: Optional[str] = None,
|
||||
provided_text_2: Optional[str] = None,
|
||||
) -> GeneratedColorPalette:
|
||||
primary = (
|
||||
_hex_to_oklch(provided_primary) if provided_primary else _generate_primary_color()
|
||||
)
|
||||
background = (
|
||||
_hex_to_oklch(provided_background)
|
||||
if provided_background
|
||||
else _generate_background_color(primary)
|
||||
)
|
||||
accent_1 = (
|
||||
_hex_to_oklch(provided_accent_1)
|
||||
if provided_accent_1
|
||||
else _generate_accent_color(primary, 1)
|
||||
)
|
||||
accent_2 = (
|
||||
_hex_to_oklch(provided_accent_2)
|
||||
if provided_accent_2
|
||||
else _generate_accent_color(primary, 2)
|
||||
)
|
||||
text_1 = (
|
||||
_hex_to_oklch(provided_text_1)
|
||||
if provided_text_1
|
||||
else _generate_text_color(background, "text_1")
|
||||
)
|
||||
text_2 = (
|
||||
_hex_to_oklch(provided_text_2)
|
||||
if provided_text_2
|
||||
else _generate_text_color(primary, "text_2")
|
||||
)
|
||||
|
||||
primary_variations = _get_color_for_all_lightness_values(primary)
|
||||
background_variations = _get_color_for_all_lightness_values(background)
|
||||
accent_1_variations = _get_color_for_all_lightness_values(accent_1)
|
||||
accent_2_variations = _get_color_for_all_lightness_values(accent_2)
|
||||
|
||||
return GeneratedColorPalette(
|
||||
primary=_format_hex(primary),
|
||||
background=_format_hex(background),
|
||||
accent_1=_format_hex(accent_1),
|
||||
accent_2=_format_hex(accent_2),
|
||||
text_1=_format_hex(text_1),
|
||||
text_2=_format_hex(text_2),
|
||||
primary_variations=primary_variations,
|
||||
background_variations=background_variations,
|
||||
accent_1_variations=accent_1_variations,
|
||||
accent_2_variations=accent_2_variations,
|
||||
primary_lightness=primary.l,
|
||||
background_lightness=background.l,
|
||||
accent_1_lightness=accent_1.l,
|
||||
accent_2_lightness=accent_2.l,
|
||||
text_1_lightness=text_1.l,
|
||||
text_2_lightness=text_2.l,
|
||||
)
|
||||
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import React, { } from 'react'
|
||||
import { defaultNavItems } from './DashboardSidebar';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
const DashboardNav = () => {
|
||||
const pathname = usePathname();
|
||||
const activeTab = pathname.split("?")[0].split("/").pop();
|
||||
const activeItem = defaultNavItems.find((i: any) => i.key === activeTab);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 right-0 z-50 py-[28px] backdrop-blur ">
|
||||
<div className="flex xl:flex-row flex-col gap-6 xl:gap-0 items-center justify-between">
|
||||
<h3 className=" text-[28px] tracking-[-0.84px] font-unbounded font-normal text-[#101828] flex items-center gap-2">
|
||||
|
||||
{activeItem?.label ?? (activeTab && activeTab?.charAt(0).toUpperCase() + activeTab?.slice(1))}
|
||||
</h3>
|
||||
<div className="flex gap-2.5 max-sm:w-full max-md:justify-center max-sm:flex-wrap">
|
||||
|
||||
|
||||
|
||||
{activeTab !== "playground" && activeTab !== "theme" && <Link
|
||||
href="/generate"
|
||||
className="inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-black text-sm font-medium shadow-sm hover:shadow-md"
|
||||
aria-label="Create new presentation"
|
||||
style={{
|
||||
borderRadius: "48px",
|
||||
background: "linear-gradient(270deg, #D5CAFC 2.4%, #E3D2EB 27.88%, #F4DCD3 69.23%, #FDE4C2 100%)",
|
||||
}}
|
||||
>
|
||||
|
||||
<span className="hidden md:inline">New presentation</span>
|
||||
<span className="md:hidden">New</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Link>}
|
||||
{activeTab === "theme" &&
|
||||
<Link
|
||||
href="/theme?tab=new-theme"
|
||||
className="inline-flex items-center font-inter font-normal gap-2 rounded-xl px-4 py-2.5 text-black text-sm shadow-sm hover:shadow-md"
|
||||
aria-label="Create new themes"
|
||||
style={{
|
||||
borderRadius: "48px",
|
||||
background: "linear-gradient(270deg, #D5CAFC 2.4%, #E3D2EB 27.88%, #F4DCD3 69.23%, #FDE4C2 100%)",
|
||||
}}
|
||||
>
|
||||
<span className="hidden md:inline">New Themes</span>
|
||||
<span className="md:hidden">New</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Link>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DashboardNav
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { LayoutDashboard, Star, Brain, Settings, Palette } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
|
||||
|
||||
export const defaultNavItems = [
|
||||
{ key: "dashboard" as const, label: "Dashboard", icon: LayoutDashboard },
|
||||
{ key: "templates" as const, label: "Standard", icon: Star },
|
||||
{ key: "designs" as const, label: "Smart", icon: Brain },
|
||||
|
||||
|
||||
|
||||
];
|
||||
export const BelongingNavItems = [
|
||||
{ key: "settings" as const, label: "Settings", icon: Settings },
|
||||
]
|
||||
|
||||
const DashboardSidebar = () => {
|
||||
|
||||
|
||||
const pathname = usePathname();
|
||||
const activeTab = pathname.split("?")[0].split("/").pop();
|
||||
const router = useRouter();
|
||||
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="sticky top-0 h-screen w-[115px] flex flex-col justify-between bg-[#F6F6F9] backdrop-blur border-r border-slate-200/60 px-4 py-8"
|
||||
aria-label="Dashboard sidebar"
|
||||
>
|
||||
<div>
|
||||
|
||||
<div onClick={() => router.push("/dashboard")} className="flex items-center pb-6 border-b border-slate-200/60 gap-2 ">
|
||||
<div className="bg-[#7C51F8] rounded-full cursor-pointer p-1 flex justify-center items-center mx-auto">
|
||||
<img src="/logo-with-bg.png" alt="Presenton logo" className="h-[40px] object-contain w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<nav className="pt-6 font-syne" aria-label="Dashboard sections">
|
||||
<div className=" space-y-6">
|
||||
|
||||
{/* Dashboard */}
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={`/dashboard`}
|
||||
className={[
|
||||
"flex flex-col tex-center items-center gap-2 transition-colors",
|
||||
pathname === "/dashboard" ? "" : "ring-transparent",
|
||||
].join(" ")}
|
||||
aria-label="Dashboard"
|
||||
title="Dashboard"
|
||||
>
|
||||
<LayoutDashboard className={["h-4 w-4", pathname === "/dashboard" ? "text-[#5146E5]" : "text-slate-600"].join(" ")} />
|
||||
<span className="text-[11px] text-slate-800">Dashboard</span>
|
||||
</Link>
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={`/templates`}
|
||||
className={[
|
||||
"flex flex-col tex-center items-center gap-2 transition-colors",
|
||||
pathname === "/templates" ? "" : "ring-transparent",
|
||||
].join(" ")}
|
||||
aria-label="Templates"
|
||||
title="Templates"
|
||||
>
|
||||
<div className="flex flex-col cursor-pointer tex-center items-center gap-2 transition-colors">
|
||||
<Star className={`h-4 w-4 ${pathname === "/templates" ? "text-[#5146E5]" : "text-slate-600"}`} />
|
||||
<span className="text-[11px] text-slate-800">Templates</span>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={`/theme`}
|
||||
className={[
|
||||
"flex flex-col tex-center items-center gap-2 transition-colors",
|
||||
pathname === "/theme" ? "" : "ring-transparent",
|
||||
].join(" ")}
|
||||
aria-label="Theme"
|
||||
title="Theme"
|
||||
>
|
||||
<div className="flex flex-col cursor-pointer tex-center items-center gap-2 transition-colors">
|
||||
<Palette className={`h-4 w-4 ${pathname === "/theme" ? "text-[#5146E5]" : "text-slate-600"}`} />
|
||||
<span className="text-[11px] text-slate-800">Themes</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className=" pt-5 border-t border-slate-200/60 font-syne "
|
||||
>
|
||||
{BelongingNavItems.map(({ key, label: itemLabel, icon: Icon }) => {
|
||||
const isActive = activeTab === key;
|
||||
return (
|
||||
<Link
|
||||
prefetch={false}
|
||||
key={key}
|
||||
href={`/${key}`}
|
||||
className={[
|
||||
"flex flex-col tex-center items-center gap-2 transition-colors ",
|
||||
isActive ? "" : "ring-transparent",
|
||||
].join(" ")}
|
||||
aria-label={itemLabel}
|
||||
title={itemLabel}
|
||||
>
|
||||
<Icon className={["h-4 w-4", isActive ? "text-[#5146E5]" : "text-slate-600"].join(" ")} />
|
||||
<span className="text-[11px] text-slate-800">{itemLabel}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardSidebar;
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
|
||||
import { DashboardApi } from "@/app/(presentation-generator)/services/api/dashboard";
|
||||
import { PresentationGrid } from "@/app/(presentation-generator)/(dashboard)/dashboard/components/PresentationGrid";
|
||||
import Link from "next/link";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
|
||||
|
||||
const DashboardPage: React.FC = () => {
|
||||
const [presentations, setPresentations] = useState<any>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
await fetchPresentations();
|
||||
};
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const fetchPresentations = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const data = await DashboardApi.getPresentations();
|
||||
data.sort(
|
||||
(a: any, b: any) =>
|
||||
new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
|
||||
);
|
||||
setPresentations(data);
|
||||
} catch (err) {
|
||||
setError(null);
|
||||
setPresentations([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removePresentation = (presentationId: string) => {
|
||||
setPresentations((prev: any) =>
|
||||
prev ? prev.filter((p: any) => p.id !== presentationId) : []
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full px-6 pb-10 relative">
|
||||
<div className="sticky top-0 right-0 z-50 py-[28px] backdrop-blur mb-4 ">
|
||||
<div className="flex xl:flex-row flex-col gap-6 xl:gap-0 items-center justify-between">
|
||||
<h3 className=" text-[28px] tracking-[-0.84px] font-unbounded font-normal text-[#101828] flex items-center gap-2">
|
||||
|
||||
Slide Presentations
|
||||
</h3>
|
||||
<div className="flex gap-2.5 max-sm:w-full max-md:justify-center max-sm:flex-wrap">
|
||||
|
||||
|
||||
|
||||
<Link
|
||||
href="/generate"
|
||||
className="inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-black text-sm font-semibold font-syne shadow-sm hover:shadow-md"
|
||||
aria-label="Create new presentation"
|
||||
style={{
|
||||
borderRadius: "48px",
|
||||
background: "linear-gradient(270deg, #D5CAFC 2.4%, #E3D2EB 27.88%, #F4DCD3 69.23%, #FDE4C2 100%)",
|
||||
}}
|
||||
>
|
||||
|
||||
<span className="hidden md:inline">New presentation</span>
|
||||
<span className="md:hidden">New</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Link>
|
||||
{/* {
|
||||
<Link
|
||||
href="/theme?tab=new-theme"
|
||||
className="inline-flex items-center font-inter font-normal gap-2 rounded-xl px-4 py-2.5 text-black text-sm shadow-sm hover:shadow-md"
|
||||
aria-label="Create new themes"
|
||||
style={{
|
||||
borderRadius: "48px",
|
||||
background: "linear-gradient(270deg, #D5CAFC 2.4%, #E3D2EB 27.88%, #F4DCD3 69.23%, #FDE4C2 100%)",
|
||||
}}
|
||||
>
|
||||
<span className="hidden md:inline">New Themes</span>
|
||||
<span className="md:hidden">New</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Link>
|
||||
} */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PresentationGrid
|
||||
presentations={presentations}
|
||||
type="slide"
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
onPresentationDeleted={removePresentation}
|
||||
/>
|
||||
<div
|
||||
className='fixed z-0 bottom-[-16.5rem] 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>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import React from 'react';
|
||||
|
||||
export const EmptyState = () => {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[70vh] bg-white/50 rounded-lg">
|
||||
<div className="mb-4">
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M42 14.4V33.6C42 40.8 38 44.8 30.8 44.8H17.2C10 44.8 6 40.8 6 33.6V14.4C6 7.2 10 3.2 17.2 3.2H30.8C38 3.2 42 7.2 42 14.4Z" stroke="#667085" strokeWidth="3" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M6.96002 16.4188H41.04" stroke="#667085" strokeWidth="3" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M19.04 3.21875V15.1388" stroke="#667085" strokeWidth="3" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M28.96 3.21875V14.2388" stroke="#667085" strokeWidth="3" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-[#101828] text-lg font-roboto font-medium mb-1">
|
||||
You don't have any presentations yet.
|
||||
</h3>
|
||||
<p className="text-[#667085] text-base font-roboto">
|
||||
Start creating the first one.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
"use client";
|
||||
|
||||
import Wrapper from "@/components/Wrapper";
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
|
||||
const Header = () => {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<div className="w-full sticky top-0 z-50 py-7 ">
|
||||
<Wrapper className="px-5 sm:px-10 lg:px-20">
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* {(pathname !== "/upload" && pathname !== "/dashboard") && <BackBtn />} */}
|
||||
<Link href="/dashboard" onClick={() => trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/dashboard" })}>
|
||||
<img
|
||||
src="/logo-with-bg.png"
|
||||
alt="Presentation logo"
|
||||
className="h-[40px] w-[40px]"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Wrapper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
'use client'
|
||||
import React from "react";
|
||||
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { DashboardApi } from "@/app/(presentation-generator)/services/api/dashboard";
|
||||
import { EllipsisVertical, Star, Trash } from "lucide-react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "@/components/ui/popover";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useFontLoader } from "@/app/(presentation-generator)/hooks/useFontLoader";
|
||||
import SlideScale from "@/app/(presentation-generator)/components/PresentationRender";
|
||||
import MarkdownRenderer from "@/components/MarkDownRender";
|
||||
|
||||
export const PresentationCard = ({
|
||||
id,
|
||||
title,
|
||||
presentation,
|
||||
onDeleted
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
presentation: any;
|
||||
onDeleted?: (presentationId: string) => void;
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
useFontLoader(presentation.fonts || []);
|
||||
const handlePreview = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
router.push(`/presentation?id=${id}&type=standard`);
|
||||
};
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
|
||||
const response = await DashboardApi.deletePresentation(id);
|
||||
|
||||
if (response) {
|
||||
toast.success("Presentation deleted", {
|
||||
description: "The presentation has been deleted successfully",
|
||||
});
|
||||
if (onDeleted) {
|
||||
onDeleted(id);
|
||||
}
|
||||
} else {
|
||||
toast.error("Error deleting presentation");
|
||||
}
|
||||
};
|
||||
const firstSlide = presentation?.slides?.[0];
|
||||
return (
|
||||
<Card
|
||||
suppressHydrationWarning={true}
|
||||
onClick={handlePreview}
|
||||
className="bg-[#F8FBFB] font-syne shadow-none sm:shadow-none presentation-card rounded-[12px] p-0 group hover:shadow-md transition-all duration-500 slide-theme cursor-pointer overflow-hidden flex flex-col"
|
||||
>
|
||||
<div suppressHydrationWarning={true} className="flex flex-col flex-1 relative z-40">
|
||||
{/* <p className=" text-xs font-syne absolute top-2 flex gap-1 capitalize items-center left-2 rounded-[100px] px-2.5 py-1 bg-[#3A3A3AF5] text-white font-semibold z-40 ">
|
||||
|
||||
{presentation.type}
|
||||
</p> */}
|
||||
|
||||
<img src="/card_bg.svg" alt="" className="absolute top-0 left-0 w-full h-full object-cover" />
|
||||
<div className="scale-[0.75] mt-4 border border-gray-300 rounded-lg overflow-hidden">
|
||||
|
||||
<SlideScale slide={firstSlide} />
|
||||
</div>
|
||||
|
||||
<div className="w-full py-3 px-5 mt-auto z-40 relative bg-white border-t border-[#EDEEEF]">
|
||||
<div className="flex items-center justify-between gap-7 w-full">
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<div className="text-sm text-[#191919] font-semibold overflow-hidden line-clamp-1">
|
||||
<MarkdownRenderer content={title} className="text-sm mb-0 text-[#191919] font-semibold overflow-hidden line-clamp-1" />
|
||||
</div>
|
||||
<p className="text-[#808080] text-sm font-syne">
|
||||
{new Date(presentation?.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger className="w-6 h-6 hover:bg-gray-100 rounded-full flex items-center justify-center text-gray-500 hover:text-gray-700" onClick={(e) => e.stopPropagation()}>
|
||||
<EllipsisVertical className="w-6 h-6 text-gray-500" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="bg-white w-[200px]">
|
||||
<button
|
||||
className="flex items-center justify-between w-full px-2 py-1 hover:bg-gray-100"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<p>Delete</p>
|
||||
<Trash className="w- h-4 text-red-500" />
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
import React from "react";
|
||||
import { PresentationCard } from "./PresentationCard";
|
||||
import { PlusIcon } from "@radix-ui/react-icons";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { PresentationResponse } from "@/app/(presentation-generator)/services/api/dashboard";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
|
||||
interface PresentationGridProps {
|
||||
presentations: PresentationResponse[];
|
||||
type: "slide" | "video";
|
||||
isLoading?: boolean;
|
||||
error?: string | null;
|
||||
onPresentationDeleted?: (presentationId: string) => void;
|
||||
}
|
||||
|
||||
export const PresentationGrid = ({
|
||||
presentations,
|
||||
type,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
onPresentationDeleted,
|
||||
}: PresentationGridProps) => {
|
||||
const router = useRouter();
|
||||
const handleCreateNewPresentation = () => {
|
||||
if (type === "slide") {
|
||||
router.push("/upload");
|
||||
} else {
|
||||
router.push("/editor");
|
||||
}
|
||||
};
|
||||
|
||||
const ShimmerCard = () => (
|
||||
<div className="flex flex-col gap-4 min-h-[200px] bg-white/70 rounded-lg p-4 animate-pulse">
|
||||
<div className="w-full h-24 bg-gray-200 rounded-lg"></div>
|
||||
<div className="space-y-3">
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const CreateNewCard = () => (
|
||||
<div
|
||||
onClick={handleCreateNewPresentation}
|
||||
className="flex flex-col cursor-pointer group ring-1 ring-inset ring-slate-200 hover:ring-[#8A7DFF]/40 bg-white/80 rounded-xl overflow-hidden transition-all duration-300 font-syne"
|
||||
>
|
||||
<img src="/create_presentation.png" alt="New Presentation" className="w-full aspect-[16/11] object-cover" />
|
||||
<div className="flex items-center gap-3 p-3 mt-auto border border-[#EDEEEF]">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="45" height="46" viewBox="0 0 45 46" fill="none" className="flex-shrink-0">
|
||||
<rect width="45" height="46" rx="8" fill="#FB6514" />
|
||||
<g clipPath="url(#clip0_1789_6104)">
|
||||
<path d="M16.0332 17.1807L28.9665 17.1807" stroke="white" strokeWidth="1.11" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M28.3197 17.1807L28.3197 24.294C28.3197 24.637 28.1834 24.966 27.9409 25.2085C27.6983 25.4511 27.3694 25.5873 27.0264 25.5873L17.973 25.5873C17.63 25.5873 17.301 25.4511 17.0585 25.2085C16.8159 24.966 16.6797 24.637 16.6797 24.294L16.6797 17.1807" stroke="white" strokeWidth="1.11" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M19.2676 28.8202L22.5009 25.5869L25.7342 28.8202" stroke="white" strokeWidth="1.11" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1789_6104">
|
||||
<rect width="15.52" height="15.52" fill="white" transform="translate(14.7402 15.2402)" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<div>
|
||||
<h4 className="text-sm text-[#191919] font-semibold tracking-[0.14px]">Create New Presentation</h4>
|
||||
<p className="text-sm text-[#808080] font-medium tracking-[0.14px] flex items-center gap-2">Get Started <ArrowRight className="w-4 h-4" /></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 px-6 mt-10 md:grid-cols-2 lg:grid-cols-4 gap-5 sm:gap-6 w-full">
|
||||
<div className="flex flex-col gap-4 min-h-[200px] cursor-pointer group ring-1 ring-inset ring-slate-200 bg-white/80 rounded-xl items-center justify-center animate-pulse">
|
||||
<div className="rounded-full bg-slate-200 p-4">
|
||||
<div className="w-8 h-8" />
|
||||
</div>
|
||||
<div className="text-center space-y-2">
|
||||
<div className="h-4 bg-slate-200 rounded w-32 mx-auto"></div>
|
||||
<div className="h-3 bg-slate-200 rounded w-48 mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
{[...Array(15)].map((_, i) => (
|
||||
<ShimmerCard key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<CreateNewCard />
|
||||
<div className="col-span-3 flex items-center justify-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<p className="mb-2">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="text-primary hover:text-primary/80 underline"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<CreateNewCard />
|
||||
{presentations &&
|
||||
presentations.length > 0 &&
|
||||
presentations.map((presentation) => (
|
||||
<PresentationCard
|
||||
key={presentation.id}
|
||||
id={presentation.id}
|
||||
title={presentation.title}
|
||||
presentation={presentation}
|
||||
onDeleted={onPresentationDeleted}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Presentation } from '../types';
|
||||
|
||||
export const PresentationListItem: React.FC<Presentation> = ({
|
||||
title,
|
||||
date,
|
||||
thumbnail,
|
||||
type
|
||||
}) => {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-4 p-4">
|
||||
<div className="relative w-[120px] aspect-video rounded-md overflow-hidden">
|
||||
<img
|
||||
src={thumbnail}
|
||||
alt={title}
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">{title}</h3>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{/* {formatDistanceToNow(new Date(date), { addSuffix: true })} */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{type === 'video' ? (
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15 12L9 8V16L15 12Z" fill="currentColor" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 4H20V16H4V4Z" stroke="currentColor" strokeWidth="2" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import React from 'react'
|
||||
|
||||
const loading = () => {
|
||||
return (
|
||||
<div className="grid grid-cols-1 px-6 mt-10 md:grid-cols-2 lg:grid-cols-4 gap-5 sm:gap-6 w-full">
|
||||
<div className="flex flex-col gap-4 min-h-[200px] cursor-pointer group ring-1 ring-inset ring-slate-200 bg-white/80 rounded-xl items-center justify-center animate-pulse">
|
||||
<div className="rounded-full bg-slate-200 p-4">
|
||||
<div className="w-8 h-8" />
|
||||
</div>
|
||||
<div className="text-center space-y-2">
|
||||
<div className="h-4 bg-slate-200 rounded w-32 mx-auto"></div>
|
||||
<div className="h-3 bg-slate-200 rounded w-48 mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
{[...Array(15)].map((_, i) => (
|
||||
<div key={i} className="flex flex-col gap-4 min-h-[200px] bg-white/70 rounded-lg p-4 animate-pulse">
|
||||
<div className="w-full h-24 bg-gray-200 rounded-lg"></div>
|
||||
<div className="space-y-3">
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default loading
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
export interface Presentation {
|
||||
id: string;
|
||||
title: string;
|
||||
date: string;
|
||||
thumbnail: string;
|
||||
type: 'video' | 'slide';
|
||||
}
|
||||
|
||||
export interface PresentationFilter {
|
||||
type?: 'video' | 'slide';
|
||||
search?: string;
|
||||
dateRange?: {
|
||||
start: Date;
|
||||
end: Date;
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import React from 'react'
|
||||
import DashboardSidebar from './Components/DashboardSidebar'
|
||||
|
||||
const layout = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<div className='flex pr-4 bg-white'>
|
||||
<DashboardSidebar />
|
||||
<div className='w-full'>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default layout
|
||||
|
|
@ -0,0 +1,355 @@
|
|||
import ToolTip from '@/components/ToolTip'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Select, SelectItem, SelectContent, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { LLMConfig } from '@/types/llm_config'
|
||||
import { DALLE_3_QUALITY_OPTIONS, GPT_IMAGE_1_5_QUALITY_OPTIONS, IMAGE_PROVIDERS } from '@/utils/providerConstants'
|
||||
import { Check, ChevronUp, Eye, EyeOff } from 'lucide-react'
|
||||
import React, { useState } from 'react'
|
||||
|
||||
const ImageProvider = ({ llmConfig, setLlmConfig }: { llmConfig: LLMConfig, setLlmConfig: (config: any) => void }) => {
|
||||
const [openImageProviderSelect, setOpenImageProviderSelect] = useState(false);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const isImageGenerationDisabled = llmConfig.DISABLE_IMAGE_GENERATION ?? false;
|
||||
const handleChangeImageGenerationDisabled = (value: boolean) => {
|
||||
setLlmConfig((prev: any) => ({
|
||||
...prev,
|
||||
DISABLE_IMAGE_GENERATION: value
|
||||
}));
|
||||
}
|
||||
const input_field_changed = (value: string, field: string) => {
|
||||
setLlmConfig((prev: any) => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}));
|
||||
setOpenImageProviderSelect(false);
|
||||
}
|
||||
|
||||
const getFieldValue = (field?: string) => {
|
||||
if (!field) return "";
|
||||
return (llmConfig as Record<string, string | undefined>)[field] || "";
|
||||
};
|
||||
|
||||
const updateFieldValue = (field: string | undefined, value: string) => {
|
||||
if (!field) return;
|
||||
setLlmConfig((prev: any) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const getTextProviderApiField = () => {
|
||||
if (llmConfig.LLM === "openai") return "OPENAI_API_KEY";
|
||||
if (llmConfig.LLM === "google") return "GOOGLE_API_KEY";
|
||||
if (llmConfig.LLM === "anthropic") return "ANTHROPIC_API_KEY";
|
||||
return "";
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const renderQualitySelector = (llmConfig: LLMConfig, input_field_changed: (value: string, field: string) => void) => {
|
||||
if (llmConfig.IMAGE_PROVIDER === "dall-e-3") {
|
||||
return (
|
||||
<div className="w-[205px] mr-0 ml-auto">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
DALL·E 3 Image Quality
|
||||
</label>
|
||||
<div className="">
|
||||
<Select value={llmConfig.DALL_E_3_QUALITY} onValueChange={(value) => input_field_changed(value, "DALL_E_3_QUALITY")}>
|
||||
<SelectTrigger className="w-full h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between">
|
||||
<SelectValue placeholder="Select a quality" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DALLE_3_QUALITY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (llmConfig.IMAGE_PROVIDER === "gpt-image-1.5") {
|
||||
return (
|
||||
<div className="w-[205px]">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
GPT Image 1.5 Quality
|
||||
</label>
|
||||
<div className="">
|
||||
<Select
|
||||
value={llmConfig.GPT_IMAGE_1_5_QUALITY}
|
||||
onValueChange={(value) => input_field_changed(value, "GPT_IMAGE_1_5_QUALITY")}
|
||||
>
|
||||
<SelectTrigger
|
||||
|
||||
className="w-full h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between">
|
||||
<SelectValue placeholder="Select a quality" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{GPT_IMAGE_1_5_QUALITY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-6 bg-[#F9F8F8] p-7 rounded-[12px] ">
|
||||
{/* API Key Input */}
|
||||
<div className="mb-4 bg-white p-10 pt-5 rounded-[12px]">
|
||||
<ToolTip content="Enable/Disable Image Generation" className='flex justify-end items-center'>
|
||||
<div className='flex justify-end items-center'>
|
||||
<Switch
|
||||
checked={!isImageGenerationDisabled}
|
||||
className='data-[state=checked]:bg-[#4791FF] data-[state=unchecked]:bg-gray-400'
|
||||
onCheckedChange={(checked) => handleChangeImageGenerationDisabled(!checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</ToolTip>
|
||||
<div className='flex items-center justify-between'>
|
||||
|
||||
|
||||
<div className=" max-w-[290px] pb-[50px]">
|
||||
<div className='w-[60px] h-[60px] px-[13.5px] py-[14.2px] rounded-[4px] flex items-center justify-center'
|
||||
style={{ backgroundColor: '#F4F3FF' }}
|
||||
>
|
||||
<img src="/image-markup.svg" className='w-full h-full object-cover' alt='image-markup' />
|
||||
</div>
|
||||
<h3 className="text-xl font-normal text-[#191919] py-2.5">Image Generation Settings</h3>
|
||||
<p className=" text-sm text-gray-500">
|
||||
Choosing where images come from
|
||||
</p>
|
||||
</div>
|
||||
<div className=' '>
|
||||
|
||||
<div className='flex items-center justify-end gap-4'>
|
||||
|
||||
{!isImageGenerationDisabled && (
|
||||
<>
|
||||
{/* Image Provider Selection */}
|
||||
<div className="">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Select Image Provider
|
||||
</label>
|
||||
<div className="w-full">
|
||||
<Popover
|
||||
open={openImageProviderSelect}
|
||||
onOpenChange={setOpenImageProviderSelect}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openImageProviderSelect}
|
||||
className="w-[205px] h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between"
|
||||
>
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{llmConfig.IMAGE_PROVIDER
|
||||
? IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER]
|
||||
?.label || llmConfig.IMAGE_PROVIDER
|
||||
: "Select image provider"}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronUp className="w-4 h-4 text-gray-500" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
align="start"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search provider..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No provider found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{Object.values(IMAGE_PROVIDERS).map(
|
||||
(provider, index) => (
|
||||
<CommandItem
|
||||
key={index}
|
||||
value={provider.value}
|
||||
onSelect={(value) => {
|
||||
input_field_changed(value, "IMAGE_PROVIDER");
|
||||
setOpenImageProviderSelect(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
llmConfig.IMAGE_PROVIDER === provider.value
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="flex flex-col space-y-1 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium text-gray-900 capitalize">
|
||||
{provider.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-600 leading-relaxed">
|
||||
{provider.description}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
)
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Dynamic API Key Input for Image Provider */}
|
||||
{llmConfig.IMAGE_PROVIDER &&
|
||||
IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER] &&
|
||||
(() => {
|
||||
const provider = IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER];
|
||||
|
||||
|
||||
|
||||
// Show ComfyUI configuration
|
||||
if (provider.value === "comfyui") {
|
||||
return (
|
||||
<div className=" space-y-4">
|
||||
<div className='w-[205px]'>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
ComfyUI Server URL
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="http://192.168.1.7:8188"
|
||||
className="w-full px-4 py-2.5 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
|
||||
value={llmConfig.COMFYUI_URL || ""}
|
||||
onChange={(e) => {
|
||||
input_field_changed(
|
||||
e.target.value,
|
||||
"COMFYUI_URL"
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show API key input for other providers
|
||||
return (
|
||||
<div className=" w-[205px]">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{provider.apiKeyFieldLabel}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
placeholder={`Enter your ${provider.apiKeyFieldLabel}`}
|
||||
className="w-full px-4 py-2.5 h-12 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
|
||||
value={getFieldValue(provider.apiKeyField)}
|
||||
onChange={(e) =>
|
||||
updateFieldValue(
|
||||
provider.apiKeyField,
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey((prev) => !prev)}
|
||||
className='absolute right-2 top-1/2 -translate-y-1/2 bg-white px-2 py-1 cursor-pointer'
|
||||
>
|
||||
{showApiKey ? <Eye className='w-4 h-4 text-gray-500' /> : <EyeOff className='w-4 h-4 text-gray-500' />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isImageGenerationDisabled && <div className='flex justify-end items-center mt-[18px]'>
|
||||
|
||||
{renderQualitySelector(llmConfig, input_field_changed)}
|
||||
{llmConfig.IMAGE_PROVIDER === "comfyui" && <div className='w-full'>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Workflow JSON
|
||||
</label>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
placeholder='Paste your ComfyUI workflow JSON here (export via "Export (API)" in ComfyUI)'
|
||||
className="w-full px-4 py-2.5 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors font-mono text-xs"
|
||||
rows={3}
|
||||
value={llmConfig.COMFYUI_WORKFLOW || ""}
|
||||
onChange={(e) => {
|
||||
input_field_changed(
|
||||
e.target.value,
|
||||
"COMFYUI_WORKFLOW"
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>}
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Web Grounding Toggle - show at the end, below models dropdown */}
|
||||
{/* <div className="bg-white flex justify-between items-center p-10 rounded-[12px]">
|
||||
<div className=' max-w-[290px]'>
|
||||
|
||||
<h4 className="text-xl font-normal text-[#191919]">Advanced</h4>
|
||||
<p className="mt-2.5 text-sm text-gray-500">
|
||||
Configure advanced AI features.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
|
||||
<div className="w-[275px]">
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
<div className="w-[295px]"></div>
|
||||
</div>
|
||||
|
||||
</div> */}
|
||||
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImageProvider
|
||||
|
|
@ -0,0 +1,347 @@
|
|||
"use client";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Loader2, Download, CheckCircle, ChevronRight } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { RootState } from "@/store/store";
|
||||
import { useSelector } from "react-redux";
|
||||
import { handleSaveLLMConfig } from "@/utils/storeHelpers";
|
||||
import {
|
||||
checkIfSelectedOllamaModelIsPulled,
|
||||
pullOllamaModel,
|
||||
} from "@/utils/providerUtils";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { LLMConfig } from "@/types/llm_config";
|
||||
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
|
||||
import SettingSideBar from "./SettingSideBar";
|
||||
import TextProvider from "./TextProvider";
|
||||
import ImageProvider from "./ImageProvider";
|
||||
import { IMAGE_PROVIDERS, LLM_PROVIDERS } from "@/utils/providerConstants";
|
||||
|
||||
// Button state interface
|
||||
interface ButtonState {
|
||||
isLoading: boolean;
|
||||
isDisabled: boolean;
|
||||
text: string;
|
||||
showProgress: boolean;
|
||||
progressPercentage?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
const SettingsPage = () => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [mode, setMode] = useState<'nanobanana' | 'presenton'>('presenton')
|
||||
const [selectedProvider, setSelectedProvider] = useState<'text-provider' | 'image-provider'>('text-provider')
|
||||
const userConfigState = useSelector((state: RootState) => state.userConfig);
|
||||
const [llmConfig, setLlmConfig] = useState<LLMConfig>(
|
||||
userConfigState.llm_config
|
||||
);
|
||||
const canChangeKeys = userConfigState.can_change_keys;
|
||||
const [buttonState, setButtonState] = useState<ButtonState>({
|
||||
isLoading: false,
|
||||
isDisabled: false,
|
||||
text: "Save Configuration",
|
||||
showProgress: false,
|
||||
});
|
||||
|
||||
const [downloadingModel, setDownloadingModel] = useState<{
|
||||
name: string;
|
||||
size: number | null;
|
||||
downloaded: number | null;
|
||||
status: string;
|
||||
done: boolean;
|
||||
} | null>(null);
|
||||
const [showDownloadModal, setShowDownloadModal] = useState<boolean>(false);
|
||||
|
||||
const downloadProgress = React.useMemo(() => {
|
||||
if (
|
||||
downloadingModel &&
|
||||
downloadingModel.downloaded !== null &&
|
||||
downloadingModel.size !== null
|
||||
) {
|
||||
return Math.round(
|
||||
(downloadingModel.downloaded / downloadingModel.size) * 100
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
}, [downloadingModel?.downloaded, downloadingModel?.size]);
|
||||
|
||||
const handleSaveConfig = async () => {
|
||||
trackEvent(MixpanelEvent.Settings_SaveConfiguration_Button_Clicked, { pathname });
|
||||
try {
|
||||
setButtonState(prev => ({
|
||||
...prev,
|
||||
isLoading: true,
|
||||
isDisabled: true,
|
||||
text: "Saving Configuration...",
|
||||
}));
|
||||
trackEvent(MixpanelEvent.Settings_SaveConfiguration_API_Call);
|
||||
await handleSaveLLMConfig(llmConfig);
|
||||
if (llmConfig.LLM === "ollama" && llmConfig.OLLAMA_MODEL) {
|
||||
trackEvent(MixpanelEvent.Settings_CheckOllamaModelPulled_API_Call);
|
||||
const isPulled = await checkIfSelectedOllamaModelIsPulled(
|
||||
llmConfig.OLLAMA_MODEL
|
||||
);
|
||||
if (!isPulled) {
|
||||
setShowDownloadModal(true);
|
||||
trackEvent(MixpanelEvent.Settings_DownloadOllamaModel_API_Call);
|
||||
await handleModelDownload();
|
||||
}
|
||||
}
|
||||
toast.info("Configuration saved successfully");
|
||||
setButtonState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
isDisabled: false,
|
||||
text: "Save Configuration",
|
||||
}));
|
||||
trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/upload" });
|
||||
router.push("/upload");
|
||||
} catch (error) {
|
||||
toast.info(error instanceof Error ? error.message : "Failed to save configuration");
|
||||
setButtonState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
isDisabled: false,
|
||||
text: "Save Configuration",
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleModelDownload = async () => {
|
||||
try {
|
||||
await pullOllamaModel(llmConfig.OLLAMA_MODEL!, setDownloadingModel);
|
||||
}
|
||||
finally {
|
||||
setDownloadingModel(null);
|
||||
setShowDownloadModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
downloadingModel &&
|
||||
downloadingModel.downloaded !== null &&
|
||||
downloadingModel.size !== null
|
||||
) {
|
||||
const percentage = Math.round(
|
||||
(downloadingModel.downloaded / downloadingModel.size) * 100
|
||||
);
|
||||
setButtonState({
|
||||
isLoading: true,
|
||||
isDisabled: true,
|
||||
text: `Downloading Model (${percentage}%)`,
|
||||
showProgress: true,
|
||||
progressPercentage: percentage,
|
||||
status: downloadingModel.status,
|
||||
});
|
||||
}
|
||||
|
||||
if (downloadingModel && downloadingModel.done) {
|
||||
setTimeout(() => {
|
||||
setShowDownloadModal(false);
|
||||
setDownloadingModel(null);
|
||||
toast.info("Model downloaded successfully!");
|
||||
}, 2000);
|
||||
}
|
||||
}, [downloadingModel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canChangeKeys) {
|
||||
router.push("/dashboard");
|
||||
}
|
||||
}, [canChangeKeys, router]);
|
||||
|
||||
if (!canChangeKeys) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
const textProviderKey = llmConfig.LLM || "openai";
|
||||
const textProviderLabel =
|
||||
LLM_PROVIDERS[textProviderKey]?.label || textProviderKey;
|
||||
const selectedTextModel =
|
||||
textProviderKey === "openai"
|
||||
? llmConfig.OPENAI_MODEL
|
||||
: textProviderKey === "google"
|
||||
? llmConfig.GOOGLE_MODEL
|
||||
: textProviderKey === "anthropic"
|
||||
? llmConfig.ANTHROPIC_MODEL
|
||||
: textProviderKey === "ollama"
|
||||
? llmConfig.OLLAMA_MODEL
|
||||
: textProviderKey === "custom"
|
||||
? llmConfig.CUSTOM_MODEL
|
||||
: textProviderKey === "codex"
|
||||
? llmConfig.CODEX_MODEL
|
||||
: "";
|
||||
const textSummary = selectedTextModel
|
||||
? `${textProviderLabel} (${selectedTextModel})`
|
||||
: textProviderLabel;
|
||||
|
||||
const imageSummary = llmConfig.DISABLE_IMAGE_GENERATION
|
||||
? "Image generation disabled"
|
||||
: llmConfig.IMAGE_PROVIDER
|
||||
? IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER]?.label || llmConfig.IMAGE_PROVIDER
|
||||
: "No image provider";
|
||||
|
||||
return (
|
||||
<div className="h-screen font-syne flex flex-col overflow-hidden relative">
|
||||
<div
|
||||
className='fixed z-0 bottom-[-14.5rem] 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%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<main className="w-full mx-auto gap-6 overflow-hidden flex ">
|
||||
<SettingSideBar mode={mode} setMode={setMode} selectedProvider={selectedProvider} setSelectedProvider={setSelectedProvider} />
|
||||
<div className="w-full">
|
||||
<div className="sticky top-0 right-0 z-50 py-[28px] backdrop-blur mb-4 ">
|
||||
<div className="flex gap-3 items-center ">
|
||||
<h3 className=" text-[28px] tracking-[-0.84px] font-unbounded font-normal text-black flex items-center gap-2">
|
||||
Settings
|
||||
</h3>
|
||||
<p className="text-[10px] px-2.5 py-0.5 rounded-[50px] text-[#7A5AF8] border border-[#EDEEEF] font-medium ">
|
||||
{textSummary} · {imageSummary}
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === 'nanobanana' && <div className=" w-full bg-[#F9F8F8] p-7 rounded-[20px]">
|
||||
<h4>Nano Banana</h4>
|
||||
</div>}
|
||||
{mode === 'presenton' && selectedProvider === 'text-provider' && <TextProvider
|
||||
|
||||
|
||||
onInputChange={(value, field) => {
|
||||
setLlmConfig(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}));
|
||||
}}
|
||||
llmConfig={llmConfig}
|
||||
/>}
|
||||
{mode === 'presenton' && selectedProvider === 'image-provider' && <ImageProvider llmConfig={llmConfig} setLlmConfig={setLlmConfig} />}
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Fixed Bottom Button */}
|
||||
<div className=" mx-auto fixed bottom-20 right-5 ">
|
||||
<button
|
||||
onClick={handleSaveConfig}
|
||||
disabled={buttonState.isDisabled}
|
||||
style={{
|
||||
background: "linear-gradient(270deg, #D5CAFC 2.4%, #E3D2EB 27.88%, #F4DCD3 69.23%, #FDE4C2 100%)",
|
||||
color: "#101323",
|
||||
}}
|
||||
className={`w-full font-syne font-semibold flex items-center justify-center gap-2 py-3 px-5 rounded-[58px] transition-all duration-500 ${buttonState.isDisabled
|
||||
? "bg-gray-400 cursor-not-allowed"
|
||||
: "bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 focus:ring-4 focus:ring-blue-200"
|
||||
} text-white`}
|
||||
>
|
||||
{buttonState.isLoading ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{buttonState.text}
|
||||
</div>
|
||||
) : (
|
||||
buttonState.text
|
||||
)}
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Download Progress Modal */}
|
||||
{showDownloadModal && downloadingModel && (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white/95 backdrop-blur-md rounded-xl shadow-2xl max-w-md w-full p-6 relative">
|
||||
{/* Modal Content */}
|
||||
<div className="text-center">
|
||||
{/* Icon */}
|
||||
<div className="mb-4">
|
||||
{downloadingModel.done ? (
|
||||
<CheckCircle className="w-12 h-12 text-green-600 mx-auto" />
|
||||
) : (
|
||||
<Download className="w-12 h-12 text-blue-600 mx-auto animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
{downloadingModel.done
|
||||
? "Download Complete!"
|
||||
: "Downloading Model"}
|
||||
</h3>
|
||||
|
||||
{/* Model Name */}
|
||||
<p className="text-sm text-gray-600 mb-6">
|
||||
{llmConfig.OLLAMA_MODEL}
|
||||
</p>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{downloadProgress > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
|
||||
<div
|
||||
className="bg-blue-600 h-3 rounded-full transition-all duration-300 ease-out"
|
||||
style={{ width: `${downloadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-2">
|
||||
{downloadProgress}% Complete
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status */}
|
||||
{downloadingModel.status && (
|
||||
<div className="flex items-center justify-center gap-2 mb-4">
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
<span className="text-sm font-medium text-green-700 capitalize">
|
||||
{downloadingModel.status}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Message */}
|
||||
{downloadingModel.status &&
|
||||
downloadingModel.status !== "pulled" && (
|
||||
<div className="text-xs text-gray-500">
|
||||
{downloadingModel.status === "downloading" &&
|
||||
"Downloading model files..."}
|
||||
{downloadingModel.status === "verifying" &&
|
||||
"Verifying model integrity..."}
|
||||
{downloadingModel.status === "pulling" &&
|
||||
"Pulling model from registry..."}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download Info */}
|
||||
{downloadingModel.downloaded && downloadingModel.size && (
|
||||
<div className="mt-4 p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex justify-between text-xs text-gray-600">
|
||||
<span>
|
||||
Downloaded:{" "}
|
||||
{(downloadingModel.downloaded / 1024 / 1024).toFixed(1)}{" "}
|
||||
MB
|
||||
</span>
|
||||
<span>
|
||||
Total: {(downloadingModel.size / 1024 / 1024).toFixed(1)}{" "}
|
||||
MB
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPage;
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import React from 'react'
|
||||
const SettingSideBar = ({ mode, setMode, selectedProvider, setSelectedProvider }: { mode: 'nanobanana' | 'presenton', setMode: (mode: 'nanobanana' | 'presenton') => void, selectedProvider: 'text-provider' | 'image-provider', setSelectedProvider: (provider: 'text-provider' | 'image-provider') => void }) => {
|
||||
return (
|
||||
<div className='w-full max-w-[230px] h-screen px-4 pt-[22px] bg-[#F9FAFB]'>
|
||||
<p className='text-xs text-black font-medium border-b mt-[3.15rem] border-[#E1E1E5] pb-3.5'>FILTER BY:</p>
|
||||
<div className='mt-6'>
|
||||
<p className='text-[#3A3A3A] text-xs font-medium pb-2.5'>Select Mode</p>
|
||||
<div className='p-1 rounded-[40px] bg-[#ffffff] w-fit border border-[#EDEEEF] flex items-center justify-center mb-[34px] '>
|
||||
<button className='px-3 py-2 text-xs font-medium text-[#3A3A3A] rounded-[70px]'
|
||||
onClick={() => setMode('presenton')}
|
||||
style={{
|
||||
background: mode === 'presenton' ? '#F4F3FF' : 'transparent',
|
||||
color: mode === 'presenton' ? '#5146E5' : '#3A3A3A'
|
||||
}}
|
||||
>Presenton</button>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className='mx-1' width="2" height="17" viewBox="0 0 2 17" fill="none">
|
||||
<path d="M1 0V16.5" stroke="#EDECEC" strokeWidth="2" />
|
||||
</svg>
|
||||
<div className='relative'>
|
||||
<button className='px-3 py-2 text-xs font-medium rounded-[70px] cursor-not-allowed opacity-60'
|
||||
disabled
|
||||
style={{
|
||||
background: 'transparent',
|
||||
color: '#9CA3AF'
|
||||
}}
|
||||
>
|
||||
Nanobanana
|
||||
</button>
|
||||
<span className='absolute -top-2 -right-5 text-[7px] uppercase tracking-wide bg-[#F4F3FF] text-[#5146E5] border border-[#D9D6FE] rounded-full px-1.5 py-0.5 whitespace-nowrap'>
|
||||
Coming soon
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<p className='text-[#3A3A3A] text-xs font-medium pb-2.5'>Select Provider</p>
|
||||
{mode === 'presenton' && <div className='space-y-2.5'>
|
||||
<button className={` w-full rounded-[6px] p-3 py-4 flex items-center gap-1.5 border ${selectedProvider === 'text-provider' ? 'bg-[#F4F3FF] border-[#D9D6FE]' : 'bg-white border-[#E1E1E5]'}`} onClick={() => setSelectedProvider('text-provider')}>
|
||||
<div className='relative w-6 h-6 rounded-full overflow-hidden border border-[#EDEEEF]'>
|
||||
|
||||
<img src='/providers/openai.png' className=' object-cover w-full h-full overflow-hidden' alt='google' />
|
||||
</div>
|
||||
<p className='text-[#191919] text-xs font-medium' >Text Provider</p>
|
||||
</button>
|
||||
<button className={` w-full rounded-[6px] p-3 py-4 flex items-center gap-1.5 border ${selectedProvider === 'image-provider' ? 'bg-[#F4F3FF] border-[#D9D6FE]' : 'bg-white border-[#E1E1E5]'}`} onClick={() => setSelectedProvider('image-provider')}>
|
||||
<div className='relative w-6 h-6 rounded-full overflow-hidden border border-[#EDEEEF]'>
|
||||
<img src='/providers/image-provider.png' className=' object-cover w-full h-full overflow-hidden' alt='google' />
|
||||
</div>
|
||||
<p className='text-[#191919] text-xs font-medium' >Image Provider</p>
|
||||
</button>
|
||||
</div>}
|
||||
{
|
||||
mode === 'nanobanana' && <div>
|
||||
<button className={` w-full rounded-[6px] p-3 py-4 flex items-center gap-1.5 border bg-[#F4F3FF] border-[#D9D6FE]`}>
|
||||
<div className='relative w-6 h-6 rounded-full overflow-hidden border border-[#EDEEEF]'>
|
||||
|
||||
<img src='/providers/openai.png' className=' object-cover w-full h-full overflow-hidden' alt='google' />
|
||||
</div>
|
||||
<p className='text-[#191919] text-xs font-medium' >Nanobanana</p>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingSideBar
|
||||
|
|
@ -0,0 +1,558 @@
|
|||
import CodexConfig from '@/components/CodexConfig';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { LLMConfig } from '@/types/llm_config';
|
||||
import { getApiUrl } from '@/utils/api';
|
||||
import { LLM_PROVIDERS } from '@/utils/providerConstants';
|
||||
import { Check, Loader2, Eye, EyeOff, ChevronUp } from 'lucide-react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner';
|
||||
|
||||
|
||||
interface OpenAIConfigProps {
|
||||
|
||||
onInputChange: (value: string | boolean, field: string) => void;
|
||||
llmConfig: LLMConfig;
|
||||
}
|
||||
const TextProvider = ({
|
||||
|
||||
onInputChange,
|
||||
llmConfig
|
||||
}: OpenAIConfigProps
|
||||
|
||||
) => {
|
||||
const [openProviderSelect, setOpenProviderSelect] = useState(false);
|
||||
const [openModelSelect, setOpenModelSelect] = useState(false);
|
||||
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const [modelsChecked, setModelsChecked] = useState(false);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const isFirstRender = useRef(true);
|
||||
|
||||
const selectedProvider = (llmConfig.LLM || 'openai') as keyof typeof LLM_PROVIDERS;
|
||||
const selectedProviderMeta = LLM_PROVIDERS[selectedProvider];
|
||||
const currentModelField = useMemo(() => {
|
||||
switch (selectedProvider) {
|
||||
case 'openai':
|
||||
return 'OPENAI_MODEL';
|
||||
case 'google':
|
||||
return 'GOOGLE_MODEL';
|
||||
case 'anthropic':
|
||||
return 'ANTHROPIC_MODEL';
|
||||
case 'ollama':
|
||||
return 'OLLAMA_MODEL';
|
||||
case 'custom':
|
||||
return 'CUSTOM_MODEL';
|
||||
case 'codex':
|
||||
return 'CODEX_MODEL';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}, [selectedProvider]);
|
||||
|
||||
const currentApiKeyField = useMemo(() => {
|
||||
switch (selectedProvider) {
|
||||
case 'openai':
|
||||
return 'OPENAI_API_KEY';
|
||||
case 'google':
|
||||
return 'GOOGLE_API_KEY';
|
||||
case 'anthropic':
|
||||
return 'ANTHROPIC_API_KEY';
|
||||
case 'custom':
|
||||
return 'CUSTOM_LLM_API_KEY';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}, [selectedProvider]);
|
||||
|
||||
const currentModel = currentModelField ? ((llmConfig as Record<string, unknown>)[currentModelField] as string || '') : '';
|
||||
const currentApiKey = currentApiKeyField ? ((llmConfig as Record<string, unknown>)[currentApiKeyField] as string || '') : '';
|
||||
const currentCustomUrl = llmConfig.CUSTOM_LLM_URL || '';
|
||||
const currentOllamaUrl = llmConfig.OLLAMA_URL || '';
|
||||
const useCustomOllamaUrl = !!llmConfig.USE_CUSTOM_URL;
|
||||
const modelLabel = selectedProviderMeta?.label || selectedProvider;
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
setAvailableModels([]);
|
||||
setModelsChecked(false);
|
||||
if (currentModelField) {
|
||||
onInputChange('', currentModelField);
|
||||
}
|
||||
}, [selectedProvider, currentApiKey, currentCustomUrl, currentModelField]);
|
||||
|
||||
|
||||
|
||||
const onApiKeyChange = (llm: keyof typeof LLM_PROVIDERS, value: string) => {
|
||||
if (llm === 'ollama') {
|
||||
onInputChange(value, 'OLLAMA_URL');
|
||||
return;
|
||||
}
|
||||
|
||||
const keyField =
|
||||
llm === 'openai'
|
||||
? 'OPENAI_API_KEY'
|
||||
: llm === 'google'
|
||||
? 'GOOGLE_API_KEY'
|
||||
: llm === 'anthropic'
|
||||
? 'ANTHROPIC_API_KEY'
|
||||
: llm === 'custom'
|
||||
? 'CUSTOM_LLM_API_KEY'
|
||||
: '';
|
||||
if (keyField) {
|
||||
onInputChange(value, keyField);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAvailableModels = async () => {
|
||||
if (selectedProvider === 'openai' && !currentApiKey) return;
|
||||
if (selectedProvider === 'google' && !currentApiKey) return;
|
||||
if (selectedProvider === 'anthropic' && !currentApiKey) return;
|
||||
if (selectedProvider === 'custom' && !currentCustomUrl) return;
|
||||
|
||||
setModelsLoading(true);
|
||||
try {
|
||||
let response: Response;
|
||||
if (selectedProvider === 'google') {
|
||||
response = await fetch(getApiUrl('/api/v1/ppt/google/models/available'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
api_key: currentApiKey
|
||||
}),
|
||||
});
|
||||
} else if (selectedProvider === 'anthropic') {
|
||||
response = await fetch(getApiUrl('/api/v1/ppt/anthropic/models/available'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
api_key: currentApiKey
|
||||
}),
|
||||
});
|
||||
} else if (selectedProvider === 'ollama') {
|
||||
response = await fetch(getApiUrl('/api/v1/ppt/ollama/models/supported'));
|
||||
} else {
|
||||
response = await fetch(getApiUrl('/api/v1/ppt/openai/models/available'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: selectedProvider === 'custom' ? currentCustomUrl : selectedProviderMeta?.url || '',
|
||||
api_key: currentApiKey
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const normalizedModels: string[] = selectedProvider === 'ollama'
|
||||
? Array.isArray(data)
|
||||
? data.map((model: { value?: string; label?: string }) => model.value || model.label || '').filter(Boolean)
|
||||
: []
|
||||
: Array.isArray(data)
|
||||
? data
|
||||
: [];
|
||||
|
||||
setAvailableModels(normalizedModels);
|
||||
setModelsChecked(true);
|
||||
|
||||
if (normalizedModels.length > 0 && currentModelField) {
|
||||
if (currentModel && normalizedModels.includes(currentModel)) {
|
||||
onInputChange(currentModel, currentModelField);
|
||||
return;
|
||||
}
|
||||
|
||||
const preferredDefault =
|
||||
selectedProvider === 'openai'
|
||||
? 'gpt-4.1'
|
||||
: selectedProvider === 'google'
|
||||
? 'models/gemini-2.5-flash'
|
||||
: selectedProvider === 'anthropic'
|
||||
? 'claude-sonnet-4-20250514'
|
||||
: normalizedModels[0];
|
||||
|
||||
const nextModel = normalizedModels.includes(preferredDefault) ? preferredDefault : normalizedModels[0];
|
||||
onInputChange(nextModel, currentModelField);
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to fetch models');
|
||||
setAvailableModels([]);
|
||||
setModelsChecked(true);
|
||||
toast.error(`Failed to fetch ${modelLabel} models`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching models:', error);
|
||||
toast.error('Error fetching models');
|
||||
setAvailableModels([]);
|
||||
setModelsChecked(true);
|
||||
} finally {
|
||||
setModelsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProvider === 'ollama' && !modelsChecked && !modelsLoading) {
|
||||
fetchAvailableModels();
|
||||
}
|
||||
}, [selectedProvider, modelsChecked, modelsLoading]);
|
||||
return (
|
||||
<div className="space-y-6 bg-[#F9F8F8] p-7 rounded-[12px] ">
|
||||
{/* API Key Input */}
|
||||
<div className="mb-4 flex items-center justify-between rounded-[12px] bg-white pt-5 pb-10 px-10">
|
||||
<div className=" max-w-[290px] pb-[50px]">
|
||||
<div className='w-[60px] h-[60px] rounded-[4px] flex items-center justify-center'
|
||||
style={{ backgroundColor: '#4C55541A' }}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
|
||||
<path d="M15.9459 5.31543V26.5767" stroke="#4C5554" strokeWidth="1.59459" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M5.31531 9.30192V6.64426C5.31531 6.29183 5.45531 5.95384 5.70451 5.70463C5.95372 5.45543 6.29171 5.31543 6.64414 5.31543H25.2477C25.6002 5.31543 25.9382 5.45543 26.1874 5.70463C26.4366 5.95384 26.5766 6.29183 26.5766 6.64426V9.30192" stroke="#4C5554" strokeWidth="1.59459" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M11.9594 26.5762H19.9324" stroke="#4C5554" strokeWidth="1.59459" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-normal text-[#191919] py-2.5">Text Generation Settings</h3>
|
||||
<p className=" text-sm text-gray-500">
|
||||
Choosing where text contets come from
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className={`flex gap-4 justify-end ${selectedProvider === 'codex' ? 'items-end' : 'items-start'}`}>
|
||||
<div className={`relative ${selectedProvider === 'codex' ? 'w-[240px]' : 'w-[222px]'}`}>
|
||||
<div className="flex flex-col justify-start ">
|
||||
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Select Text Provider
|
||||
</label>
|
||||
<Popover
|
||||
open={openProviderSelect}
|
||||
onOpenChange={setOpenProviderSelect}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openProviderSelect}
|
||||
className="w-[222px] h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between"
|
||||
>
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{llmConfig.LLM
|
||||
? LLM_PROVIDERS[llmConfig.LLM]
|
||||
?.label || llmConfig.LLM
|
||||
: "Select text provider"}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronUp className="w-4 h-4 text-gray-500" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
align="start"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search provider..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No provider found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{Object.values(LLM_PROVIDERS).map(
|
||||
(provider, index) => (
|
||||
<CommandItem
|
||||
key={index}
|
||||
value={provider.value}
|
||||
onSelect={(value) => {
|
||||
onInputChange(value, "LLM");
|
||||
setOpenProviderSelect(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
llmConfig.LLM === provider.value
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="flex flex-col space-y-1 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium text-gray-900 capitalize">
|
||||
{provider.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-600 leading-relaxed">
|
||||
{provider.description}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
)
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<div className={`relative flex flex-col justify-end ${selectedProvider === 'codex' ? 'items-end w-[262px] max-w-full' : 'items-end w-[222px]'}`}>
|
||||
<div className="flex flex-col justify-start w-full ">
|
||||
{selectedProvider === 'ollama' ? (
|
||||
<>
|
||||
{!useCustomOllamaUrl ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onInputChange(true, 'USE_CUSTOM_URL');
|
||||
if (!currentOllamaUrl) {
|
||||
onInputChange('http://localhost:11434', 'OLLAMA_URL');
|
||||
}
|
||||
}}
|
||||
className="mt-8 py-2.5 bg-[#EDEEEF] px-3.5 w-fit rounded-[48px] text-xs font-semibold text-[#101323] transition-all duration-200 border border-[#EDEEEF] hover:bg-[#E8F0FF]/90 focus:ring-2 focus:ring-blue-500/20"
|
||||
>
|
||||
Use Ollama URL
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<label className="block text-sm font-medium capitalize text-gray-700 mb-2">
|
||||
Ollama URL
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={currentOllamaUrl}
|
||||
onChange={(e) => onApiKeyChange(selectedProvider, e.target.value)}
|
||||
className="w-full px-2 py-3 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
|
||||
placeholder="http://localhost:11434"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onInputChange(false, 'USE_CUSTOM_URL');
|
||||
onInputChange('http://localhost:11434', 'OLLAMA_URL');
|
||||
}}
|
||||
className="mt-2 text-xs font-medium text-[#4B5563] underline underline-offset-2"
|
||||
>
|
||||
Use default Ollama URL
|
||||
</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`}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
value={currentApiKey}
|
||||
onChange={(e) => onApiKeyChange(selectedProvider, e.target.value)}
|
||||
className="w-full px-2 py-3 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
|
||||
placeholder={`Enter your ${llmConfig.LLM} API key`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey((prev) => !prev)}
|
||||
className='absolute right-2 top-1/2 -translate-y-1/2 bg-white px-2 py-1 cursor-pointer'
|
||||
>
|
||||
{showApiKey ? <Eye className='w-4 h-4 text-gray-500' /> : <EyeOff className='w-4 h-4 text-gray-500' />}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{selectedProvider === 'custom' && (
|
||||
<input
|
||||
type="text"
|
||||
value={currentCustomUrl}
|
||||
onChange={(e) => onInputChange(e.target.value, 'CUSTOM_LLM_URL')}
|
||||
className="w-full mt-2 px-2 py-3 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
|
||||
placeholder="OpenAI-compatible URL"
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
{selectedProvider !== 'ollama' && selectedProvider !== 'codex' && (!modelsChecked || (modelsChecked && availableModels.length === 0)) && (
|
||||
|
||||
<button
|
||||
onClick={fetchAvailableModels}
|
||||
disabled={
|
||||
modelsLoading ||
|
||||
(selectedProvider === 'openai' && !currentApiKey) ||
|
||||
(selectedProvider === 'google' && !currentApiKey) ||
|
||||
(selectedProvider === 'anthropic' && !currentApiKey) ||
|
||||
(selectedProvider === 'custom' && !currentCustomUrl)
|
||||
}
|
||||
className={`mt-4 py-2.5 bg-[#EDEEEF] px-3.5 w-fit rounded-[48px] text-xs font-semibold text-[#101323] transition-all duration-200 border ${modelsLoading
|
||||
? " border-gray-300 cursor-not-allowed text-gray-500"
|
||||
: " border-[#EDEEEF] text-[#101323] hover:bg-[#E8F0FF]/90 focus:ring-2 focus:ring-blue-500/20"
|
||||
}`}
|
||||
>
|
||||
{modelsLoading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Checking for models...
|
||||
</span>
|
||||
) : (
|
||||
"Check models"
|
||||
)}
|
||||
</button>
|
||||
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Model Selection - only show if models are available */}
|
||||
{selectedProvider !== 'codex' && modelsChecked && availableModels.length > 0 ? (
|
||||
<div className="w-[222px]">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
{selectedProvider === 'ollama' ? 'Choose a supported model' : `Select ${modelLabel} Model`}
|
||||
</label>
|
||||
<div className="w-full">
|
||||
<Popover
|
||||
open={openModelSelect}
|
||||
onOpenChange={setOpenModelSelect}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openModelSelect}
|
||||
className="w-full h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between"
|
||||
>
|
||||
<span className="text-sm truncate font-medium text-gray-900">
|
||||
{currentModel
|
||||
? availableModels.find(model => model === currentModel) || currentModel
|
||||
: "Select a model"}
|
||||
</span>
|
||||
|
||||
<ChevronUp className="w-4 h-4 text-gray-500" />
|
||||
</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>
|
||||
{availableModels.map((model, index) => (
|
||||
<CommandItem
|
||||
key={index}
|
||||
value={model}
|
||||
onSelect={(value) => {
|
||||
if (currentModelField) {
|
||||
onInputChange(value, currentModelField);
|
||||
}
|
||||
setOpenModelSelect(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
currentModel === model
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="flex flex-col space-y-1 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{model}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Show message if no models found */}
|
||||
{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.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Web Grounding Toggle - show at the end, below models dropdown */}
|
||||
<div className="bg-white flex justify-between items-center p-10 rounded-[12px]">
|
||||
<div className=' max-w-[290px]'>
|
||||
|
||||
<h4 className="text-xl font-normal text-[#191919]">Advanced</h4>
|
||||
<p className="mt-2.5 text-sm text-gray-500">
|
||||
Configure advanced AI features.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
|
||||
<div className="w-[222px]">
|
||||
<div className="flex items-center mb-4 gap-2.5 ">
|
||||
<Switch
|
||||
checked={!!llmConfig.WEB_GROUNDING}
|
||||
onCheckedChange={(checked) => onInputChange(checked, "WEB_GROUNDING")}
|
||||
/>
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
Enable Web Grounding
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
{/* <div className="w-[295px]"></div> */}
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TextProvider
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import { Card } from "@/components/ui/card";
|
||||
|
||||
export default function LoadingProfile() {
|
||||
return (
|
||||
<div className="h-screen bg-gradient-to-b font-instrument_sans from-gray-50 to-white flex flex-col overflow-hidden">
|
||||
{/* Header Skeleton */}
|
||||
<div className="flex-shrink-0 bg-white border-b border-gray-200 p-4">
|
||||
<div className="container mx-auto max-w-3xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-8 w-32 bg-gray-200 animate-pulse rounded-md" />
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-8 w-8 bg-gray-200 animate-pulse rounded-full" />
|
||||
<div className="h-8 w-24 bg-gray-200 animate-pulse rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Skeleton */}
|
||||
<main className="flex-1 container mx-auto px-4 max-w-3xl overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{/* LLM Selection Content Skeleton */}
|
||||
<div className="space-y-6 p-6">
|
||||
{/* Page Title */}
|
||||
<div className="space-y-2">
|
||||
<div className="h-8 w-48 bg-gray-200 animate-pulse rounded-md" />
|
||||
<div className="h-5 w-72 bg-gray-200 animate-pulse rounded-md" />
|
||||
</div>
|
||||
|
||||
{/* LLM Provider Cards */}
|
||||
<div className="space-y-4">
|
||||
{[...Array(3)].map((_, index) => (
|
||||
<Card key={index} className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 bg-gray-200 animate-pulse rounded-md" />
|
||||
<div className="space-y-1">
|
||||
<div className="h-5 w-32 bg-gray-200 animate-pulse rounded-md" />
|
||||
<div className="h-4 w-48 bg-gray-200 animate-pulse rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-6 w-6 bg-gray-200 animate-pulse rounded-full" />
|
||||
</div>
|
||||
|
||||
{/* Configuration Fields */}
|
||||
<div className="space-y-4">
|
||||
{[...Array(2)].map((_, fieldIndex) => (
|
||||
<div key={fieldIndex} className="space-y-2">
|
||||
<div className="h-4 w-24 bg-gray-200 animate-pulse rounded-md" />
|
||||
<div className="h-10 w-full bg-gray-200 animate-pulse rounded-md" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Model Selection */}
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="h-5 w-32 bg-gray-200 animate-pulse rounded-md" />
|
||||
<div className="h-10 w-full bg-gray-200 animate-pulse rounded-md" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Fixed Bottom Button Skeleton */}
|
||||
<div className="flex-shrink-0 bg-white border-t border-gray-200 p-4">
|
||||
<div className="container mx-auto max-w-3xl">
|
||||
<div className="h-12 w-full bg-gray-200 animate-pulse rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { Plus, Sparkles } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React from 'react'
|
||||
|
||||
const CreateCustomTemplate = () => {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div
|
||||
onClick={() => {
|
||||
router.push('/custom-template')
|
||||
}}
|
||||
className='w-full rounded-xl border border-[#EDEEEF] cursor-pointer font-syne'>
|
||||
<div className='relative h-[215px] flex justify-center items-center '>
|
||||
<img src="/card_bg.svg" alt="" className="absolute top-0 z-[1] left-0 w-full h-full object-cover" />
|
||||
<div className='w-[36px] h-[36px] relative z-[4] rounded-full bg-[#7A5AF8] flex items-center justify-center'
|
||||
style={{
|
||||
background: 'linear-gradient(0deg, rgba(0, 0, 0, 0.20) 0%, rgba(0, 0, 0, 0.20) 100%), #FFF'
|
||||
}}
|
||||
><div className='w-[26px] h-[26px] rounded-full bg-white flex items-center justify-center'>
|
||||
|
||||
<Plus className='w-4 h-4 text-[#A2A0A1]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='px-5 py-4 bg-white flex items-center gap-4 border-t border-[#EDEEEF]'>
|
||||
<div className='bg-[#7A5AF8] w-[45px] h-[45px] rounded-lg p-2 flex items-center justify-center'>
|
||||
|
||||
<Sparkles className='w-6 h-6 text-white' />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className='text-[#191919] text-sm font-semibold '>Build Template</h4>
|
||||
<p className='flex text-[#808080] text-sm font-medium items-center gap-2'>Build Your Own Template</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreateCustomTemplate
|
||||
|
|
@ -0,0 +1,278 @@
|
|||
"use client";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { ArrowUpRight, ChevronRight, ExternalLink, Loader2, Plus } from "lucide-react";
|
||||
import { templates } from "@/app/presentation-templates";
|
||||
import { TemplateWithData, TemplateLayoutsWithSettings } from "@/app/presentation-templates/utils";
|
||||
import {
|
||||
useCustomTemplateSummaries,
|
||||
useCustomTemplatePreview,
|
||||
CustomTemplates,
|
||||
} from "@/app/hooks/useCustomTemplates";
|
||||
import { CompiledLayout } from "@/app/hooks/compileLayout";
|
||||
import CreateCustomTemplate from "./CreateCustomTemplate";
|
||||
import Link from "next/link";
|
||||
|
||||
// Component for rendering custom template card with lazy-loaded previews
|
||||
export const CustomTemplateCard = React.memo(function CustomTemplateCard({ template }: { template: CustomTemplates }) {
|
||||
const router = useRouter();
|
||||
const { previewLayouts, loading, totalLayouts } = useCustomTemplatePreview(`${template.id}`);
|
||||
const handleOpen = useCallback(() => {
|
||||
if (template.id.startsWith('custom-')) {
|
||||
router.push(`/template-preview?slug=${template.id}`)
|
||||
} else {
|
||||
router.push(`/template-preview?slug=custom-${template.id}`)
|
||||
}
|
||||
}
|
||||
, [router, template.id]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="cursor-pointer flex flex-col justify-between shadow-none sm:shadow-none relative hover:shadow-lg transition-all duration-200 group overflow-hidden"
|
||||
onClick={handleOpen}
|
||||
>
|
||||
|
||||
<img src="/card_bg.svg" alt="" className="absolute top-0 left-0 w-full h-full object-cover" />
|
||||
<span className="text-xs font-syne absolute top-2 flex gap-1 capitalize items-center left-2 rounded-[100px] px-2.5 py-1 bg-[#3A3A3AF5] text-white font-semibold z-40">
|
||||
Layouts- {totalLayouts}
|
||||
</span>
|
||||
<div className="p-5">
|
||||
|
||||
{/* Layout previews */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{loading ? (
|
||||
// Loading placeholders
|
||||
[...Array(Math.min(4, template.layoutCount))].map((_, index) => (
|
||||
<div
|
||||
key={`${template.id}-loading-${index}`}
|
||||
className="relative bg-linear-to-br from-purple-50 to-blue-50 border border-gray-200 overflow-hidden aspect-video rounded flex items-center justify-center"
|
||||
>
|
||||
<Loader2 className="w-4 h-4 text-purple-300 animate-spin" />
|
||||
</div>
|
||||
))
|
||||
) : previewLayouts.length > 0 && (
|
||||
// Actual layout previews
|
||||
previewLayouts.slice(0, 4).map((layout: CompiledLayout, index: number) => {
|
||||
const LayoutComponent = layout.component;
|
||||
return (
|
||||
<div
|
||||
key={`${template.id}-preview-${index}`}
|
||||
className="relative bg-gray-100 border border-gray-200 overflow-hidden aspect-video rounded"
|
||||
>
|
||||
<div className="absolute inset-0 bg-transparent z-10" />
|
||||
<div
|
||||
className="transform scale-[0.12] origin-top-left"
|
||||
style={{ width: "833.33%", height: "833.33%" }}
|
||||
>
|
||||
<LayoutComponent data={layout.sampleData} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-5 bg-white border-t border-[#EDEEEF] relative z-40 ">
|
||||
<h3 className="text-sm font-bold w-[191px] text-gray-900">
|
||||
{template.name}
|
||||
</h3>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
<ArrowUpRight className="w-4 h-4 text-gray-400 group-hover:text-purple-600 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}, (prev, next) => {
|
||||
// Custom templates may be refetched, producing new object references; compare on fields we render/use.
|
||||
return (
|
||||
prev.template.id === next.template.id &&
|
||||
prev.template.id === next.template.id &&
|
||||
prev.template.name === next.template.name &&
|
||||
prev.template.layoutCount === next.template.layoutCount
|
||||
);
|
||||
});
|
||||
|
||||
const InbuiltTemplateCard = React.memo(function InbuiltTemplateCard({
|
||||
template,
|
||||
onOpen,
|
||||
}: {
|
||||
template: TemplateLayoutsWithSettings;
|
||||
onOpen: (id: string) => void;
|
||||
}) {
|
||||
const previewLayouts = useMemo(() => template.layouts.slice(0, 4), [template.layouts]);
|
||||
const handleOpen = useCallback(() => onOpen(template.id), [onOpen, template.id]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={template.id}
|
||||
className="cursor-pointer relative sm:shadow-none shadow-none hover:shadow-lg transition-all duration-200 group overflow-hidden"
|
||||
onClick={handleOpen}
|
||||
>
|
||||
<span className="text-xs font-syne absolute top-2 flex gap-1 capitalize items-center left-2 rounded-[100px] px-2.5 py-1 bg-[#3A3A3AF5] text-white font-semibold z-40">
|
||||
Layouts- {template.layouts.length}
|
||||
</span>
|
||||
<img src="/card_bg.svg" alt="" className="absolute top-0 left-0 w-full h-full object-cover" />
|
||||
<div className="p-5">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{previewLayouts.map((layout: TemplateWithData, index: number) => {
|
||||
const LayoutComponent = layout.component;
|
||||
return (
|
||||
<div
|
||||
key={`${template.id}-preview-${index}`}
|
||||
className="relative bg-gray-100 border border-gray-200 overflow-hidden aspect-video rounded"
|
||||
>
|
||||
<div className="absolute inset-0 bg-transparent z-10" />
|
||||
<div
|
||||
className="transform scale-[0.12] origin-top-left"
|
||||
style={{ width: "833.33%", height: "833.33%" }}
|
||||
>
|
||||
<LayoutComponent data={layout.sampleData} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-5 bg-white border-t border-[#EDEEEF] relative z-40 ">
|
||||
<div className="w-[191px]">
|
||||
|
||||
<h3 className="text-sm font-bold text-gray-900 capitalize">
|
||||
{template.name}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-600 mb-4 line-clamp-2">
|
||||
{template.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
<ArrowUpRight className="w-4 h-4 text-gray-400 group-hover:text-blue-600 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
const LayoutPreview = () => {
|
||||
const [tab, setTab] = useState<'custom' | 'default'>('default');
|
||||
const router = useRouter();
|
||||
const { templates: customTemplates, loading: customLoading } = useCustomTemplateSummaries();
|
||||
|
||||
useEffect(() => {
|
||||
const existingScript = document.querySelector('script[src*="tailwindcss.com"]');
|
||||
if (!existingScript) {
|
||||
const script = document.createElement("script");
|
||||
script.src = "https://cdn.tailwindcss.com";
|
||||
script.async = true;
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleOpenPreview = useCallback((id: string) => router.push(`/template-preview?slug=${id}`), [router]);
|
||||
|
||||
|
||||
|
||||
|
||||
const inbuiltTemplateCards = useMemo(
|
||||
() =>
|
||||
templates.map((template: TemplateLayoutsWithSettings) => (
|
||||
<InbuiltTemplateCard key={template.id} template={template} onOpen={handleOpenPreview} />
|
||||
)),
|
||||
[handleOpenPreview],
|
||||
);
|
||||
|
||||
const customTemplateCards = useMemo(
|
||||
() => customTemplates.map((template: CustomTemplates) => <CustomTemplateCard key={template.id} template={template} />),
|
||||
[customTemplates],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen relative font-syne">
|
||||
<div
|
||||
className='fixed z-0 -bottom-[16.5rem] 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="sticky top-0 right-0 z-50 py-[28px] px-6 backdrop-blur ">
|
||||
<div className="flex xl:flex-row flex-col gap-6 xl:gap-0 items-center justify-between">
|
||||
<h3 className=" text-[28px] tracking-[-0.84px] font-unbounded font-normal text-[#101828] flex items-center gap-2">
|
||||
Templates
|
||||
</h3>
|
||||
<div className="flex gap-2.5 max-sm:w-full max-md:justify-center max-sm:flex-wrap">
|
||||
|
||||
|
||||
|
||||
|
||||
<Link
|
||||
href="/custom-template"
|
||||
className="inline-flex items-center font-syne font-semibold gap-2 rounded-xl px-4 py-2.5 text-black text-sm shadow-sm hover:shadow-md"
|
||||
aria-label="Create new template"
|
||||
style={{
|
||||
borderRadius: "48px",
|
||||
background: "linear-gradient(270deg, #D5CAFC 2.4%, #E3D2EB 27.88%, #F4DCD3 69.23%, #FDE4C2 100%)",
|
||||
}}
|
||||
>
|
||||
<span className="hidden md:inline">New Template</span>
|
||||
<span className="md:hidden">New</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Link>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="l mx-auto px-6 py-8">
|
||||
<div className='p-1 rounded-[40px] bg-[#ffffff] w-fit border border-[#EDEEEF] flex items-center justify-center '>
|
||||
<button className='px-5 py-2 text-xs font-medium text-[#3A3A3A] rounded-[70px]'
|
||||
onClick={() => setTab('custom')}
|
||||
style={{
|
||||
background: tab === 'custom' ? '#F4F3FF' : 'transparent',
|
||||
color: tab === 'custom' ? '#5146E5' : '#3A3A3A'
|
||||
}}
|
||||
>Custom</button>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className='mx-1' width="2" height="17" viewBox="0 0 2 17" fill="none">
|
||||
<path d="M1 0V16.5" stroke="#EDECEC" strokeWidth="2" />
|
||||
</svg>
|
||||
<button className='px-5 py-2 text-xs font-medium text-[#3A3A3A] rounded-[70px]'
|
||||
onClick={() => setTab('default')}
|
||||
style={{
|
||||
background: tab === 'default' ? '#F4F3FF' : 'transparent',
|
||||
color: tab === 'default' ? '#5146E5' : '#3A3A3A'
|
||||
}}
|
||||
>Built-in</button>
|
||||
</div>
|
||||
|
||||
{/* Inbuilt Templates Section */}
|
||||
{tab === 'default' && <section className="my-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{inbuiltTemplateCards}
|
||||
</div>
|
||||
</section>}
|
||||
|
||||
|
||||
{tab === 'custom' && <section className="my-12">
|
||||
{customLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
|
||||
<span className="ml-3 text-gray-600">Loading custom templates...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<CreateCustomTemplate />
|
||||
{customTemplateCards}
|
||||
</div>
|
||||
)}
|
||||
</section>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LayoutPreview;
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Card } from '@/components/ui/card'
|
||||
|
||||
const TemplateCardSkeleton = () => (
|
||||
<Card className="overflow-hidden shadow-none sm:shadow-none relative">
|
||||
<Skeleton className="absolute top-2 left-2 h-6 w-20 rounded-full z-40" />
|
||||
<img src="/card_bg.svg" alt="" className="absolute top-0 left-0 w-full h-full object-cover" />
|
||||
<div className="p-5">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="aspect-video rounded" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-5 bg-white border-t border-[#EDEEEF] relative z-40">
|
||||
<div className="w-[191px]">
|
||||
<Skeleton className="h-4 w-28 mb-2" />
|
||||
<Skeleton className="h-3 w-full mb-1" />
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-4" />
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
|
||||
const Loading = () => {
|
||||
return (
|
||||
<div className="min-h-screen relative font-syne">
|
||||
<div
|
||||
className="fixed z-0 -bottom-[16.5rem] 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="sticky top-0 right-0 z-50 py-[28px] px-6 backdrop-blur">
|
||||
<div className="flex xl:flex-row flex-col gap-6 xl:gap-0 items-center justify-between">
|
||||
<Skeleton className="h-[34px] w-[180px] rounded-lg" />
|
||||
<div className="flex gap-2.5 max-sm:w-full max-md:justify-center max-sm:flex-wrap">
|
||||
<Skeleton className="h-[42px] w-[160px] rounded-[48px]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto px-6 py-8">
|
||||
<div className="p-1 rounded-[40px] bg-[#ffffff] w-fit border border-[#EDEEEF] flex items-center justify-center">
|
||||
<Skeleton className="h-8 w-20 rounded-[70px]" />
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="mx-1" width="2" height="17" viewBox="0 0 2 17" fill="none">
|
||||
<path d="M1 0V16.5" stroke="#EDECEC" strokeWidth="2" />
|
||||
</svg>
|
||||
<Skeleton className="h-8 w-20 rounded-[70px]" />
|
||||
</div>
|
||||
|
||||
<section className="my-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{Array.from({ length: 4 }).map((_, idx) => (
|
||||
<TemplateCardSkeleton key={idx} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Loading
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import React from 'react'
|
||||
import TemplatePanel from './components/TemplatePanel'
|
||||
|
||||
const page = () => {
|
||||
return (
|
||||
<TemplatePanel />
|
||||
)
|
||||
}
|
||||
|
||||
export default page
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
"use client";
|
||||
import React from 'react'
|
||||
|
||||
import { HexColorPicker, HexColorInput } from 'react-colorful'
|
||||
import { ThemeColors } from './types'
|
||||
|
||||
interface ColorPickerComponentProps {
|
||||
colorKey: keyof ThemeColors
|
||||
currentColor: string
|
||||
onColorChange: (colorKey: keyof ThemeColors, value: string) => void
|
||||
showColorPicker: string | null
|
||||
onShowColorPicker: (colorKey: string | null) => void,
|
||||
label: string
|
||||
}
|
||||
|
||||
export const ColorPickerComponent: React.FC<ColorPickerComponentProps> = ({
|
||||
colorKey,
|
||||
currentColor,
|
||||
onColorChange,
|
||||
showColorPicker,
|
||||
onShowColorPicker,
|
||||
label
|
||||
}) => (
|
||||
<div className="">
|
||||
{label && <p className="text-xs text-[#38393D] font-medium pb-1.5">
|
||||
{label}
|
||||
</p>}
|
||||
<div className="flex gap-2 border border-[#EDEEEF] rounded-md p-1">
|
||||
<div
|
||||
className="w-8 h-8 rounded border border-gray-300 cursor-pointer relative"
|
||||
style={{ backgroundColor: currentColor }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onShowColorPicker(showColorPicker === colorKey ? null : colorKey)
|
||||
}}
|
||||
>
|
||||
{showColorPicker === colorKey && (
|
||||
<div
|
||||
className="absolute top-full left-0 z-[9999] mt-2 bg-white border border-gray-300 rounded-lg shadow-lg p-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<HexColorPicker
|
||||
color={currentColor}
|
||||
onChange={(color) => onColorChange(colorKey, color)}
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<HexColorInput
|
||||
color={currentColor}
|
||||
onChange={(color) => onColorChange(colorKey, color)}
|
||||
className="w-full px-2 py-1 text-sm border border-gray-300 rounded outline-none "
|
||||
prefixed
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
className='w-full outline-none text-sm font-medium text-[#38393D]'
|
||||
value={currentColor}
|
||||
onChange={(e) => onColorChange(colorKey, e.target.value)}
|
||||
placeholder="#000000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
"use client";
|
||||
import { ArrowRight, Plus, Sparkle, Sparkles } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import React from 'react'
|
||||
|
||||
const CustomTabEmpty = () => {
|
||||
const router = useRouter()
|
||||
return (
|
||||
<div
|
||||
onClick={() => {
|
||||
router.push('/theme?tab=new-theme')
|
||||
}}
|
||||
className='w-[305px] rounded-xl border border-[#EDEEEF] cursor-pointer'>
|
||||
<div className='relative h-[250px] flex justify-center items-center '>
|
||||
<img src="/card_bg.svg" alt="" className="absolute top-0 z-[1] left-0 w-full h-full object-cover" />
|
||||
<div className='w-[36px] h-[36px] relative z-[4] rounded-full bg-[#7A5AF8] flex items-center justify-center'
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, #F00 5.21%, #FF8A00 16.48%, #FFE600 27.74%, #14FF00 39.35%, #00A3FF 49.37%, #0500FF 61.18%, #AD00FF 72.26%, #FF00C7 83.53%, #F00 94.61%), #FFF'
|
||||
}}
|
||||
><div className='w-[26px] h-[26px] rounded-full bg-white flex items-center justify-center'>
|
||||
|
||||
<Plus className='w-4 h-4 text-[#A2A0A1]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='px-5 py-4 bg-white flex items-center gap-4 border-t border-[#EDEEEF]'>
|
||||
<div className='bg-[#7A5AF8] w-[45px] h-[45px] rounded-lg p-2 flex items-center justify-center'>
|
||||
|
||||
<Sparkles className='w-6 h-6 text-white' />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className='text-[#191919] text-sm font-semibold '>Build Theme</h4>
|
||||
<p className='flex text-[#808080] text-sm font-medium items-center gap-2'>From colors <ArrowRight className='w-3 h-3' /> fonts </p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomTabEmpty
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
"use client";
|
||||
import React from 'react'
|
||||
import { Check } from 'lucide-react'
|
||||
|
||||
interface FontCardProps {
|
||||
font: any
|
||||
isSelected: boolean
|
||||
onSelect: (fontName: string) => void
|
||||
}
|
||||
|
||||
export const FontCard: React.FC<FontCardProps> = ({ font, isSelected, onSelect }) => (
|
||||
<div
|
||||
className={`relative p-3 rounded-xl cursor-pointer transition-all duration-200 group
|
||||
${isSelected
|
||||
? 'bg-gradient-to-br from-[#F8F7FF] to-[#F0EFFF] border border-[#7A5AF8] shadow-sm'
|
||||
: 'bg-white border border-[#EDEEEF] hover:border-[#C4B5FD] hover:bg-[#FAFAFF]'
|
||||
}`}
|
||||
onClick={() => onSelect(font.name)}
|
||||
>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className={`text-sm font-medium truncate ${isSelected ? 'text-[#7A5AF8]' : 'text-[#151515]'}`}
|
||||
style={{ fontFamily: `"${font.name}"` }}
|
||||
>
|
||||
{font.displayName}
|
||||
</p>
|
||||
<p
|
||||
className="text-[11px] text-[#A6A4A2] mt-0.5"
|
||||
style={{ fontFamily: `"${font.name}"` }}
|
||||
>
|
||||
ABC abc 123
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={`text-xl font-semibold ${isSelected ? 'text-[#7A5AF8]' : 'text-[#333] group-hover:text-[#7A5AF8]'} transition-colors`}
|
||||
style={{ fontFamily: `"${font.name}"` }}
|
||||
>
|
||||
Aa
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import React from 'react'
|
||||
|
||||
interface StepIndicatorProps {
|
||||
currentStep: number
|
||||
}
|
||||
|
||||
const steps = [
|
||||
{ step: 1, label: 'Brand' },
|
||||
{ step: 2, label: 'Palette' },
|
||||
{ step: 3, label: 'Fonts' },
|
||||
{ step: 4, label: 'Logo' },
|
||||
]
|
||||
|
||||
export const StepIndicator: React.FC<StepIndicatorProps> = ({ currentStep }) => (
|
||||
<div className="flex flex-col items-center gap-7 px-4 min-w-[104px] pt-8 border-r border-[#EDEEEF]">
|
||||
{steps.map(({ step, label }) => {
|
||||
const isActive = currentStep === step
|
||||
return (
|
||||
<div key={step} className="flex flex-col items-center gap-1.5 px-3 ">
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded-full text-[9px] font-medium ${isActive
|
||||
? 'bg-[#7A5AF8] text-white'
|
||||
: 'bg-white text-[#404348] border border-[#EDEEEF]'
|
||||
}`}
|
||||
>
|
||||
Step-{step}
|
||||
</span>
|
||||
<span className="text-[11px] font-normal text-black">{label}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
"use client";
|
||||
import React, { useState } from 'react'
|
||||
import { AlertTriangle, Check, Copy, Trash } from 'lucide-react'
|
||||
import { Theme } from '@/app/(presentation-generator)/services/api/types'
|
||||
import ToolTip from '@/components/ToolTip'
|
||||
|
||||
interface ThemeCardProps {
|
||||
theme: Theme
|
||||
onSelect: (theme: Theme) => void
|
||||
onDelete: (themeId: string) => void
|
||||
showDeleteButton?: boolean
|
||||
}
|
||||
|
||||
export const ThemeCard: React.FC<ThemeCardProps> = ({ theme, onSelect, onDelete, showDeleteButton = true }) => {
|
||||
if (!theme.data.colors['graph_0']) { return null }
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
|
||||
|
||||
|
||||
return (<div
|
||||
className={` group rounded-xl border w-[305px] cursor-pointer transition-all relative bg-white border-[#EDEEEF] hover:shadow-md`}
|
||||
onClick={() => onSelect(theme)}
|
||||
|
||||
>
|
||||
{showDeleteButton && <button
|
||||
className="absolute hidden group-hover:block duration-300 transition-all -top-3 -right-3 z-10 bg-white rounded-full p-2 border border-[#EDEEEF] hover:bg-gray-100 hover:text-gray-700"
|
||||
style={{ boxShadow: '0 6.6px 13.2px 0 rgba(0, 0, 0, 0.10)' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowDeleteDialog(true)
|
||||
}}
|
||||
>
|
||||
<Trash className="h-3 w-3" />
|
||||
</button>}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
{showDeleteDialog && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center animate-[fadeIn_150ms_ease-out]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowDeleteDialog(false)
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-[2px]" />
|
||||
<div
|
||||
className="relative bg-white rounded-2xl w-[340px] shadow-2xl animate-[scaleIn_200ms_ease-out] "
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-6 pb-4 flex flex-col items-center text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-red-50 flex items-center justify-center mb-4">
|
||||
<AlertTriangle className="h-6 w-6 text-red-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-[#191919] mb-2">Delete Theme?</h3>
|
||||
<p className="text-sm text-gray-500 leading-relaxed">
|
||||
You're about to delete <span className="font-medium text-gray-700">"{theme.name}"</span>. This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex border-t border-gray-100">
|
||||
<button
|
||||
onClick={() => setShowDeleteDialog(false)}
|
||||
className="flex-1 px-4 py-3.5 text-sm font-medium text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onDelete(theme.id)
|
||||
setShowDeleteDialog(false)
|
||||
}}
|
||||
className="flex-1 px-4 py-3.5 text-sm font-medium text-red-500 hover:bg-red-50 transition-colors border-l border-gray-100"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div className='relative h-[250px] flex justify-center items-center '>
|
||||
|
||||
<img src="/card_bg.svg" alt="" className="absolute top-0 z-[1] left-0 w-[99%] h-full object-cover" />
|
||||
<div className=" absolute top-0 left-0 flex items-center justify-between gap-2 z-[2] p-2">
|
||||
<ToolTip content='Font' >
|
||||
|
||||
<p className=" text-xs font-syne flex gap-1 capitalize items-center rounded-[100px] px-2.5 py-1 bg-[#3A3A3AF5] text-white font-semibold z-40 ">
|
||||
|
||||
{theme.data.fonts.textFont.name}
|
||||
</p>
|
||||
</ToolTip>
|
||||
{theme.company_name && <ToolTip content='COMPANY'>
|
||||
|
||||
<p className=" text-xs font-syne flex gap-1 capitalize items-center rounded-[100px] px-2.5 py-1 bg-[#3A3A3AF5] text-white font-semibold text-ellipsis overflow-hidden whitespace-nowrap z-40 ">
|
||||
|
||||
{theme.company_name}
|
||||
</p>
|
||||
</ToolTip>}
|
||||
{theme.logo_url && <ToolTip content='LOGO'>
|
||||
|
||||
<p className=" text-xs font-syne flex gap-1 capitalize items-center rounded-[100px] px-2.5 py-1 bg-[#3A3A3AF5] text-white font-semibold z-40 ">
|
||||
|
||||
<img src={theme.logo_url} alt={theme.name} className="w-full max-w-6 h-4 rounded-full object-cover" />
|
||||
</p>
|
||||
</ToolTip>}
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
<div className=" relative z-[3] px-6">
|
||||
|
||||
<div className="w-full h-[135px]">
|
||||
<div
|
||||
className=" w-full h-full rounded-xl p-3 border border-black/10 "
|
||||
style={{ backgroundColor: theme.data.colors['background'] }}
|
||||
>
|
||||
<div
|
||||
className="h-[calc(100%-2px)] w-[calc(100%-2px)] mx-auto my-auto rounded-xl p-4 border border-black/10 shadow-[0_2px_6px_rgba(0,0,0,0.10)]"
|
||||
style={{ backgroundColor: theme.data.colors['card'] }}
|
||||
>
|
||||
<div className="h-full w-full flex flex-col justify-center">
|
||||
<div
|
||||
className="text-[22px] font-semibold leading-[1.05] text-left truncate"
|
||||
style={{ color: theme.data.colors['background_text'], fontFamily: `"${theme.data.fonts.textFont.name}", ui-serif, Georgia, serif` }}
|
||||
>
|
||||
{theme.name}
|
||||
</div>
|
||||
<div
|
||||
className="mt-1 text-base font-medium leading-[1.1] text-left truncate"
|
||||
style={{ color: theme.data.colors['background_text'], fontFamily: `"${theme.data.fonts.textFont.name}", ui-serif, Georgia, serif` }}
|
||||
>
|
||||
Choose your preferences.
|
||||
</div>
|
||||
<div
|
||||
className="mt-2 h-2.5 w-16 rounded-full"
|
||||
style={{ backgroundColor: theme.data.colors['primary'] }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className='px-5 border-t rounded-b-xl border-[#EDEEEF] w-full py-2.5 h-[80px] bg-white flex items-center justify-between'>
|
||||
<div>
|
||||
|
||||
<h4 className='text-sm font-semibold text-[#191919] pb-1'>{theme.name}</h4>
|
||||
<div className='flex items-center gap-1'>
|
||||
|
||||
<div className='w-4 h-4 rounded-full border border-[#EDEEEF] '
|
||||
style={{ backgroundColor: theme.data.colors['primary'] }}
|
||||
/>
|
||||
<div
|
||||
className='w-4 h-4 rounded-full border border-[#EDEEEF] '
|
||||
style={{ backgroundColor: theme.data.colors['background'] }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(theme.id)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}}
|
||||
className={copied ? "text-green-500" : "text-gray-500 hover:text-gray-700"}
|
||||
title={copied ? "Copied!" : "Copy ID"}
|
||||
>
|
||||
{copied ? <Check className="h-5 w-5" /> : <Copy className="h-5 w-5" />}
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>)
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
|
||||
|
||||
export const FONT_OPTIONS: any[] = [
|
||||
{ name: 'Inter', displayName: 'Inter', cssUrl: 'https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap' },
|
||||
{ name: 'DM Sans', displayName: 'DM Sans', cssUrl: 'https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&display=swap' },
|
||||
{ name: 'Overpass', displayName: 'Overpass', cssUrl: 'https://fonts.googleapis.com/css2?family=Overpass:wght@100..900&display=swap' },
|
||||
{ name: 'Barlow', displayName: 'Barlow', cssUrl: 'https://fonts.googleapis.com/css2?family=Barlow:wght@100..900&display=swap' },
|
||||
{ name: 'Nunito', displayName: 'Nunito', cssUrl: 'https://fonts.googleapis.com/css2?family=Nunito:wght@200..1000&display=swap' },
|
||||
{ name: 'Lora', displayName: 'Lora', cssUrl: 'https://fonts.googleapis.com/css2?family=Lora:wght@400;500;600;700&display=swap' },
|
||||
{ name: 'Instrument Sans', displayName: 'Instrument Sans', cssUrl: 'https://fonts.googleapis.com/css2?family=Instrument+Sans:ital,wght@0,400..700;1,400..700&display=swap' },
|
||||
{ name: 'Roboto Slab', displayName: 'Roboto Slab', cssUrl: 'https://fonts.googleapis.com/css2?family=Roboto+Slab:wght@100..900&display=swap' },
|
||||
{ name: 'Montserrat', displayName: 'Montserrat', cssUrl: 'https://fonts.googleapis.com/css2?family=Montserrat:wght@100..900&display=swap' },
|
||||
{ name: 'Libre Baskerville', displayName: 'Libre Baskerville', cssUrl: 'https://fonts.googleapis.com/css2?family=Libre+Baskerville:wght@400;700&display=swap' },
|
||||
{ name: 'Prompt', displayName: 'Prompt', cssUrl: 'https://fonts.googleapis.com/css2?family=Prompt:wght@100..900&display=swap' },
|
||||
{ name: 'Inconsolata', displayName: 'Inconsolata', cssUrl: 'https://fonts.googleapis.com/css2?family=Inconsolata:wght@200..900&display=swap' },
|
||||
{ name: 'Fraunces', displayName: 'Fraunces', cssUrl: 'https://fonts.googleapis.com/css2?family=Fraunces:wght@300..900&display=swap' },
|
||||
{ name: 'Gelasio', displayName: 'Gelasio', cssUrl: 'https://fonts.googleapis.com/css2?family=Gelasio:wght@300..700&display=swap' },
|
||||
{ name: 'Raleway', displayName: 'Raleway', cssUrl: 'https://fonts.googleapis.com/css2?family=Raleway:wght@100..900&display=swap' },
|
||||
{ name: 'Kanit', displayName: 'Kanit', cssUrl: 'https://fonts.googleapis.com/css2?family=Kanit:wght@100..900&display=swap' },
|
||||
{ name: 'Corben', displayName: 'Corben', cssUrl: 'https://fonts.googleapis.com/css2?family=Corben:wght@400;700&display=swap' },
|
||||
{ name: 'Poppins', displayName: 'Poppins', cssUrl: 'https://fonts.googleapis.com/css2?family=Poppins:wght@100..900&display=swap' },
|
||||
{ name: 'Open Sans', displayName: 'Open Sans', cssUrl: 'https://fonts.googleapis.com/css2?family=Open+Sans:wght@300..800&display=swap' },
|
||||
{ name: 'Lato', displayName: 'Lato', cssUrl: 'https://fonts.googleapis.com/css2?family=Lato:wght@100..900&display=swap' },
|
||||
{ name: 'Source Sans Pro', displayName: 'Source Sans Pro', cssUrl: 'https://fonts.googleapis.com/css2?family=Source+Sans+Pro:wght@200..900&display=swap' },
|
||||
{ name: 'Playfair Display', displayName: 'Playfair Display', cssUrl: 'https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400..900&display=swap' },
|
||||
{ name: 'Roboto', displayName: 'Roboto', cssUrl: 'https://fonts.googleapis.com/css2?family=Roboto:wght@100..900&display=swap' }
|
||||
]
|
||||
|
||||
export const DEFAULT_THEMES: any[] = [
|
||||
{
|
||||
id: "edge-yellow",
|
||||
name: "Edge Yellow",
|
||||
description: "Yellow and dark theme for professionalish and edge.",
|
||||
logo: null,
|
||||
logo_url: null,
|
||||
company_name: null,
|
||||
|
||||
data: {
|
||||
colors: {
|
||||
primary: "#f5f547",
|
||||
background: "#1f1f1f",
|
||||
card: "#424242",
|
||||
stroke: "#585858",
|
||||
primary_text: "#161616",
|
||||
background_text: "#f5f547",
|
||||
graph_0: "#ffff54",
|
||||
graph_1: "#f1f142",
|
||||
graph_2: "#dada15",
|
||||
graph_3: "#c1bf00",
|
||||
graph_4: "#a8a600",
|
||||
graph_5: "#908c00",
|
||||
graph_6: "#797400",
|
||||
graph_7: "#625c00",
|
||||
graph_8: "#4d4500",
|
||||
graph_9: "#382f00"
|
||||
},
|
||||
fonts: {
|
||||
textFont: {
|
||||
name: "Playfair Display",
|
||||
url: "https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400..900&display=swap"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "light-rose",
|
||||
name: "Light Rose",
|
||||
description: "Rose background with punchy font",
|
||||
logo: null,
|
||||
logo_url: null,
|
||||
company_name: null,
|
||||
|
||||
data: {
|
||||
colors: {
|
||||
"primary": "#030204",
|
||||
background: "#f69c9c",
|
||||
card: "#ffaeb4",
|
||||
stroke: "#bf6a6b",
|
||||
primary_text: "#bebebe",
|
||||
background_text: "#030202",
|
||||
graph_0: "#2f2c32",
|
||||
graph_1: "#444147",
|
||||
graph_2: "#5a565d",
|
||||
graph_3: "#706d73",
|
||||
graph_4: "#88848b",
|
||||
graph_5: "#a09da4",
|
||||
graph_6: "#b9b6bd",
|
||||
graph_7: "#d3cfd6",
|
||||
graph_8: "#eae6ed",
|
||||
graph_9: "#f7f3fb"
|
||||
},
|
||||
fonts: {
|
||||
textFont: {
|
||||
name: "Overpass",
|
||||
url: "https://fonts.googleapis.com/css2?family=Overpass:wght@100..900&display=swap"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "mint-blue",
|
||||
name: "Mint Blue",
|
||||
description: "Mint Greent with blue heading.",
|
||||
logo: null,
|
||||
logo_url: null,
|
||||
company_name: null,
|
||||
|
||||
data: {
|
||||
colors: {
|
||||
primary: "#3b3172",
|
||||
background: "#ffffff",
|
||||
card: "#80e7cf",
|
||||
stroke: "#d1d1d1",
|
||||
primary_text: "#ffffff",
|
||||
background_text: "#3b3172",
|
||||
graph_0: "#003d2d",
|
||||
graph_1: "#005341",
|
||||
graph_2: "#006a57",
|
||||
graph_3: "#00826d",
|
||||
graph_4: "#2b9a85",
|
||||
graph_5: "#4ab39d",
|
||||
graph_6: "#65cdb6",
|
||||
graph_7: "#80e7cf",
|
||||
graph_8: "#98ffe6",
|
||||
graph_9: "#a5fff4"
|
||||
},
|
||||
fonts: {
|
||||
textFont: {
|
||||
name: "Prompt",
|
||||
url: "https://fonts.googleapis.com/css2?family=Prompt:wght@100..900&display=swap"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "professional-blue",
|
||||
name: "Professional Blue",
|
||||
description: "Clean and professional blue theme",
|
||||
logo: null,
|
||||
logo_url: null,
|
||||
company_name: null,
|
||||
|
||||
data: {
|
||||
colors: {
|
||||
primary: "#161616",
|
||||
background: "#ffffff",
|
||||
card: "#dae6ff",
|
||||
stroke: "#d1d1d1",
|
||||
primary_text: "#eeeaea",
|
||||
background_text: "#000000",
|
||||
graph_0: "#2e2e2e",
|
||||
graph_1: "#424242",
|
||||
graph_2: "#585858",
|
||||
graph_3: "#6f6f6f",
|
||||
graph_4: "#868686",
|
||||
graph_5: "#9e9e9e",
|
||||
graph_6: "#b7b7b7",
|
||||
graph_7: "#d1d1d1",
|
||||
graph_8: "#e8e8e8",
|
||||
graph_9: "#f5f5f5"
|
||||
},
|
||||
fonts: {
|
||||
textFont: {
|
||||
name: "Inter",
|
||||
url: "https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "professional-dark",
|
||||
name: "Professional Dark",
|
||||
description: "Clean and professional for dark corporate usage.",
|
||||
logo: null,
|
||||
logo_url: null,
|
||||
company_name: null,
|
||||
|
||||
data: {
|
||||
colors: {
|
||||
primary: "#eff5f1",
|
||||
background: "#050505",
|
||||
card: "#424242",
|
||||
stroke: "#585858",
|
||||
primary_text: "#050505",
|
||||
background_text: "#eff5f1",
|
||||
graph_0: "#ebf6ff",
|
||||
graph_1: "#dee8fa",
|
||||
graph_2: "#c7d2e3",
|
||||
graph_3: "#aeb8c9",
|
||||
graph_4: "#959fb0",
|
||||
graph_5: "#7d8797",
|
||||
graph_6: "#666f7f",
|
||||
graph_7: "#505867",
|
||||
graph_8: "#3a4351",
|
||||
graph_9: "#262e3c"
|
||||
},
|
||||
fonts: {
|
||||
textFont: {
|
||||
name: "Instrument Sans",
|
||||
url: "https://fonts.googleapis.com/css2?family=Instrument+Sans:ital,wght@0,400..700;1,400..700&display=swap"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,20 @@
|
|||
export interface ThemeColors {
|
||||
'primary': string
|
||||
'background': string
|
||||
'card': string
|
||||
'stroke': string
|
||||
'primary_text': string
|
||||
'background_text': string
|
||||
'graph_0': string
|
||||
'graph_1': string
|
||||
'graph_2': string
|
||||
'graph_3': string
|
||||
'graph_4': string
|
||||
'graph_5': string
|
||||
'graph_6': string
|
||||
'graph_7': string
|
||||
'graph_8': string
|
||||
'graph_9': string
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
export const loadGoogleFont = (fontFamily: string) => {
|
||||
// Check if font is already loaded
|
||||
const existingLink = document.querySelector(`link[href*="${fontFamily.replace(' ', '+')}"]`)
|
||||
if (existingLink) return
|
||||
|
||||
const link = document.createElement('link')
|
||||
link.href = `https://fonts.googleapis.com/css2?family=${fontFamily.replace(' ', '+')}:wght@300;400;500;600;700&display=swap`
|
||||
link.rel = 'stylesheet'
|
||||
document.head.appendChild(link)
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
const ThemeCardSkeleton = () => (
|
||||
<div className="rounded-xl border w-[305px] bg-white border-[#EDEEEF]">
|
||||
<div className="relative h-[250px] flex justify-center items-center">
|
||||
<img src="/card_bg.svg" alt="" className="absolute top-0 z-[1] left-0 w-[99%] h-full object-cover" />
|
||||
<div className="absolute top-0 left-0 flex items-center gap-2 z-[2] p-2">
|
||||
<Skeleton className="h-6 w-16 rounded-full" />
|
||||
<Skeleton className="h-6 w-20 rounded-full" />
|
||||
</div>
|
||||
<div className="relative z-[3] px-6 w-full">
|
||||
<div className="w-full h-[135px] rounded-xl overflow-hidden">
|
||||
<Skeleton className="w-full h-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 border-t rounded-b-xl border-[#EDEEEF] w-full py-2.5 h-[80px] bg-white flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="h-4 w-24 mb-2" />
|
||||
<div className="flex items-center gap-1">
|
||||
<Skeleton className="w-4 h-4 rounded-full" />
|
||||
<Skeleton className="w-4 h-4 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const Loading = () => {
|
||||
return (
|
||||
<div className="space-y-6 px-6 font-syne">
|
||||
<div className="py-[28px] flex justify-between">
|
||||
<Skeleton className="h-[34px] w-[140px] rounded-lg" />
|
||||
<Skeleton className="h-[42px] w-[140px] rounded-[48px]" />
|
||||
</div>
|
||||
|
||||
<div className="p-1 rounded-[40px] bg-[#F7F6F9] w-fit border border-[#F4F4F4] flex items-center justify-center">
|
||||
<Skeleton className="h-8 w-20 rounded-[70px]" />
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="mx-1" width="2" height="17" viewBox="0 0 2 17" fill="none">
|
||||
<path d="M1 0V16.5" stroke="#EDECEC" strokeWidth="2" />
|
||||
</svg>
|
||||
<Skeleton className="h-8 w-20 rounded-[70px]" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-6">
|
||||
{Array.from({ length: 4 }).map((_, idx) => (
|
||||
<ThemeCardSkeleton key={idx} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Loading
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import React from 'react'
|
||||
import ThemePanel from './components/ThemePanel'
|
||||
const page = () => {
|
||||
return (
|
||||
<ThemePanel />
|
||||
)
|
||||
}
|
||||
|
||||
export default page
|
||||
|
|
@ -18,7 +18,7 @@ const HeaderNav = () => {
|
|||
<Link
|
||||
href="/dashboard"
|
||||
prefetch={false}
|
||||
className="flex items-center gap-2 px-3 py-2 text-white hover:bg-primary/80 rounded-md transition-colors outline-none"
|
||||
className="flex items-center gap-2 px-3 py-2 text-[#101323] rounded-md transition-colors outline-none"
|
||||
role="menuitem"
|
||||
onClick={() => trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/dashboard" })}
|
||||
>
|
||||
|
|
@ -31,7 +31,7 @@ const HeaderNav = () => {
|
|||
<Link
|
||||
href="/settings"
|
||||
prefetch={false}
|
||||
className="flex items-center gap-2 px-3 py-2 text-white hover:bg-primary/80 rounded-md transition-colors outline-none"
|
||||
className="flex items-center gap-2 px-3 py-2 text-[#101323] rounded-md transition-colors outline-none"
|
||||
role="menuitem"
|
||||
onClick={() => trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/settings" })}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -357,7 +357,9 @@ const ImageEditor = ({
|
|||
<div className="grid grid-cols-2 gap-4 ">
|
||||
{previousGeneratedImages.map((image) => (
|
||||
<div
|
||||
onClick={() => handleImageChange(image.file_url || image.path)}
|
||||
onClick={() =>
|
||||
handleImageChange(image.file_url || image.path)
|
||||
}
|
||||
key={image.id}
|
||||
className="aspect-[4/3] w-full overflow-hidden rounded-lg border cursor-pointer hover:border-blue-500 transition-colors"
|
||||
>
|
||||
|
|
@ -483,7 +485,7 @@ const ImageEditor = ({
|
|||
handleDeleteImage(image.id)
|
||||
}}/>
|
||||
<img
|
||||
src={image.file_url || image.path}
|
||||
src={image.file_url || image.path}
|
||||
alt="Uploaded preview"
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useEffect } from "react"
|
||||
import { useEditor, EditorContent } from "@tiptap/react"
|
||||
import StarterKit from "@tiptap/starter-kit"
|
||||
import { Markdown } from "tiptap-markdown"
|
||||
|
|
@ -19,12 +20,14 @@ export default function MarkdownEditor({ content, onChange }: { content: string;
|
|||
immediatelyRender: false,
|
||||
});
|
||||
|
||||
// Update editor content when the content prop changes (for streaming)
|
||||
// useEffect(() => {
|
||||
// if (editor && content !== editor.storage.markdown.getMarkdown()) {
|
||||
// editor.commands.setContent(content);
|
||||
// }
|
||||
// }, [content, editor]);
|
||||
// Keep editor state in sync when parent changes content (e.g. reorder)
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
const currentMarkdown = editor.storage.markdown.getMarkdown();
|
||||
if (content !== currentMarkdown) {
|
||||
editor.commands.setContent(content, false);
|
||||
}
|
||||
}, [content, editor]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ export const V1ContentRender = ({ slide, isEditMode, theme }: { slide: any, isEd
|
|||
if (isEditMode) {
|
||||
return (
|
||||
<SlideErrorBoundary label={`Slide ${slide.index + 1}`}>
|
||||
<div ref={containerRef} className={`w-full h-full `}>
|
||||
<div ref={containerRef} className={`w-full h-full border border-[#EDEEEF] `}>
|
||||
|
||||
<EditableLayoutWrapper
|
||||
slideIndex={slide.index}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from "react";
|
||||
import Header from "@/app/(presentation-generator)/dashboard/components/Header";
|
||||
import Header from "@/app/(presentation-generator)/(dashboard)/dashboard/components/Header";
|
||||
|
||||
export const APIKeyWarning: React.FC = () => {
|
||||
return (
|
||||
|
|
@ -8,7 +8,7 @@ export const APIKeyWarning: React.FC = () => {
|
|||
<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.
|
||||
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">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import Header from "@/app/(presentation-generator)/dashboard/components/Header";
|
||||
import Header from "@/app/(presentation-generator)/(dashboard)/dashboard/components/Header";
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
message: string;
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export const SaveLayoutModal: React.FC<SaveLayoutModalProps> = ({
|
|||
const id = await onSave(layoutName.trim(), description.trim());
|
||||
if (id) {
|
||||
// Redirect to the new template preview page
|
||||
router.push(`/template-preview/custom-${id}`);
|
||||
router.push(`/template-preview?slug=custom-${id}`);
|
||||
}
|
||||
// Reset form after navigation decision
|
||||
setLayoutName("");
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ export const useFontManagement = () => {
|
|||
const formData = new FormData();
|
||||
formData.append("font_file", file);
|
||||
|
||||
const response = await fetch(getApiUrl("api/v1/ppt/fonts/upload"), {
|
||||
const response = await fetch(getApiUrl("/api/v1/ppt/fonts/upload"), {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export const useLayoutSaving = (
|
|||
|
||||
while (retryCount < maxRetries) {
|
||||
try {
|
||||
const response = await fetch(getApiUrl("api/v1/ppt/html-to-react/"), {
|
||||
const response = await fetch(getApiUrl("/api/v1/ppt/html-to-react/"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
|
@ -137,7 +137,7 @@ export const useLayoutSaving = (
|
|||
}
|
||||
|
||||
// First create/update the template metadata
|
||||
await fetch(getApiUrl("api/v1/ppt/template-management/templates"), {
|
||||
await fetch(getApiUrl("/api/v1/ppt/template-management/templates"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id: presentationId, name: layoutName, description }),
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ export const useSlideEdit = (
|
|||
formData.append("html", currentHtml);
|
||||
formData.append("prompt", prompt);
|
||||
|
||||
const response = await fetch(getApiUrl("api/v1/ppt/html-edit/"), {
|
||||
const response = await fetch(getApiUrl("/api/v1/ppt/html-edit/"), {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export const useSlideProcessing = (
|
|||
);
|
||||
|
||||
try {
|
||||
const htmlResponse = await fetch(getApiUrl("api/v1/ppt/slide-to-html/"), {
|
||||
const htmlResponse = await fetch(getApiUrl("/api/v1/ppt/slide-to-html/"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
|
@ -134,7 +134,7 @@ export const useSlideProcessing = (
|
|||
let slidesResponseData: any = null;
|
||||
if (isPdf) {
|
||||
formData.append("pdf_file", selectedFile);
|
||||
const pdfResponse = await fetch(getApiUrl("api/v1/ppt/pdf-slides/process"), {
|
||||
const pdfResponse = await fetch(getApiUrl("/api/v1/ppt/pdf-slides/process"), {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
|
@ -144,7 +144,7 @@ export const useSlideProcessing = (
|
|||
);
|
||||
} else if (isPptx) {
|
||||
formData.append("pptx_file", selectedFile);
|
||||
const pptxResponse = await fetch(getApiUrl("api/v1/ppt/pptx-slides/process"), {
|
||||
const pptxResponse = await fetch(getApiUrl("/api/v1/ppt/pptx-slides/process"), {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import React, { useEffect } from "react";
|
||||
import FontManager from "./components/FontManager";
|
||||
import Header from "../dashboard/components/Header";
|
||||
import Header from "../(dashboard)/dashboard/components/Header";
|
||||
|
||||
import { useCustomLayout } from "./hooks/useCustomLayout";
|
||||
import { useFontManagement } from "./hooks/useFontManagement";
|
||||
|
|
@ -47,7 +47,7 @@ const CustomTemplatePage = () => {
|
|||
trackEvent(MixpanelEvent.CustomTemplate_Save_Templates_API_Call);
|
||||
const id = await saveLayout(layoutName, description);
|
||||
if (id) {
|
||||
router.push(`/template-preview/custom-${id}`);
|
||||
router.push(`/template-preview?slug=custom-${id}`);
|
||||
}
|
||||
return id;
|
||||
};
|
||||
|
|
@ -94,7 +94,7 @@ const CustomTemplatePage = () => {
|
|||
|
||||
}
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
|
||||
<div className="min-h-screen bg-linear-to-br from-slate-50 to-slate-100">
|
||||
<Header />
|
||||
<div className="max-w-[1440px] aspect-video mx-auto px-6">
|
||||
{/* Header */}
|
||||
|
|
|
|||
|
|
@ -21,8 +21,6 @@ export interface ProcessedSlide extends SlideData {
|
|||
error?: string;
|
||||
modified?: boolean;
|
||||
convertingToReact?: boolean; // indicates HTML-to-React conversion in progress
|
||||
react?: string; // React component code
|
||||
layout_name?: string; // Layout name
|
||||
}
|
||||
|
||||
export interface FontData {
|
||||
|
|
|
|||
|
|
@ -36,9 +36,9 @@ const Header = () => {
|
|||
<span className="text-sm font-medium font-inter">Create Template</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/template-preview"
|
||||
href="/templates"
|
||||
prefetch={false}
|
||||
onClick={() => trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/template-preview" })}
|
||||
onClick={() => trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/templates" })}
|
||||
className="flex items-center gap-2 px-3 py-2 text-white hover:bg-primary/80 rounded-md transition-colors outline-none"
|
||||
role="menuitem"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
|
||||
export const useFontLoader = (fonts: Record<string, string>) => {
|
||||
const injectFonts = () => {
|
||||
if (typeof document === 'undefined' || !fonts || typeof fonts !== 'object') return;
|
||||
|
||||
const ensureStylesheetLink = (href: string) => {
|
||||
const existing = document.querySelector(`link[rel="stylesheet"][data-font-url="${href}"]`);
|
||||
if (existing) return;
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.setAttribute('data-font-url', href);
|
||||
link.href = href;
|
||||
document.head.appendChild(link);
|
||||
};
|
||||
|
||||
const ensureFontFaceStyle = (name: string, srcUrl: string) => {
|
||||
const existing = document.querySelector(`style[data-font-url="${srcUrl}"]`);
|
||||
if (existing) return;
|
||||
const styleEl = document.createElement('style');
|
||||
styleEl.setAttribute('data-font-url', srcUrl);
|
||||
styleEl.textContent = `@font-face {\n font-family: '${name}';\n src: url('${srcUrl}');\n font-style: normal;\n font-display: swap;\n}`;
|
||||
document.head.appendChild(styleEl);
|
||||
};
|
||||
|
||||
Object.entries(fonts).forEach(([name, url]) => {
|
||||
if (!name || !url) return;
|
||||
const isCss = /\.css(\?|$)/i.test(url) || /fonts\.googleapis\.com/.test(url);
|
||||
if (isCss) {
|
||||
ensureStylesheetLink(url);
|
||||
} else {
|
||||
ensureFontFaceStyle(name, url);
|
||||
}
|
||||
});
|
||||
};
|
||||
injectFonts();
|
||||
};
|
||||
|
|
@ -28,26 +28,21 @@ LayoutPreview.displayName = 'LayoutPreview';
|
|||
|
||||
export const CustomTemplateCard = memo(({ template, onSelectTemplate, selectedTemplate }: { template: CustomTemplates, onSelectTemplate: (template: string) => void, selectedTemplate: string | null }) => {
|
||||
|
||||
const { previewLayouts, loading: customLoading } = useCustomTemplatePreview(template.id);
|
||||
const { previewLayouts, loading: customLoading, totalLayouts } = useCustomTemplatePreview(template.id);
|
||||
const isSelected = selectedTemplate === template.id;
|
||||
|
||||
return (
|
||||
|
||||
<Card
|
||||
className={`${isSelected ? 'border-2 border-blue-500' : ''} cursor-pointer hover:shadow-lg transition-all duration-200 group overflow-hidden relative`}
|
||||
style={{ contain: 'layout style paint' }}
|
||||
onClick={() => {
|
||||
onSelectTemplate(template.id);
|
||||
}}
|
||||
className={`${isSelected ? 'border-2 border-blue-500' : ''} font-syne cursor-pointer flex flex-col justify-between relative hover:shadow-lg transition-all duration-200 group overflow-hidden`}
|
||||
onClick={() => onSelectTemplate(template.id)}
|
||||
>
|
||||
|
||||
<img src="/card_bg.svg" alt="" className="absolute top-0 left-0 w-full h-full object-cover" />
|
||||
<span className="text-xs font-syne absolute top-2 flex gap-1 capitalize items-center left-2 rounded-[100px] px-2.5 py-1 bg-[#3A3A3AF5] text-white font-semibold z-40">
|
||||
Layouts- {totalLayouts}
|
||||
</span>
|
||||
<div className="p-5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-xl font-bold text-gray-900">
|
||||
{template.name}
|
||||
</h3>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Layout previews */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
|
|
@ -61,36 +56,37 @@ export const CustomTemplateCard = memo(({ template, onSelectTemplate, selectedTe
|
|||
<Loader2 className="w-4 h-4 text-purple-300 animate-spin" />
|
||||
</div>
|
||||
))
|
||||
) : previewLayouts && previewLayouts?.length > 0 ? (
|
||||
// Actual layout previews - using memoized component
|
||||
previewLayouts?.slice(0, 4).map((layout: CompiledLayout, index: number) => (
|
||||
<LayoutPreview
|
||||
key={`${template.id}-preview-${index}`}
|
||||
layout={layout}
|
||||
templateId={template.id}
|
||||
index={index}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
// Empty state placeholders
|
||||
[...Array(Math.min(4, template.layoutCount))].map((_, index) => (
|
||||
<div
|
||||
key={`${template.id}-empty-${index}`}
|
||||
className="relative bg-gray-100 border border-gray-200 overflow-hidden aspect-video rounded flex items-center justify-center"
|
||||
>
|
||||
<span className="text-xs text-gray-400">No preview</span>
|
||||
</div>
|
||||
))
|
||||
) : previewLayouts.length > 0 && (
|
||||
// Actual layout previews
|
||||
previewLayouts.slice(0, 4).map((layout: CompiledLayout, index: number) => {
|
||||
const LayoutComponent = layout.component;
|
||||
return (
|
||||
<div
|
||||
key={`${template.id}-preview-${index}`}
|
||||
className="relative bg-gray-100 border border-gray-200 overflow-hidden aspect-video rounded"
|
||||
>
|
||||
<div className="absolute inset-0 bg-transparent z-10" />
|
||||
<div
|
||||
className="transform scale-[0.12] origin-top-left"
|
||||
style={{ width: "833.33%", height: "833.33%" }}
|
||||
>
|
||||
<LayoutComponent data={layout.sampleData} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="absolute top-0 right-0 bg-blue-500 text-white px-2 py-1 rounded-bl-lg">
|
||||
Selected
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between p-5 bg-white border-t border-[#EDEEEF] relative z-40 ">
|
||||
<h3 className="text-sm font-bold text-gray-900">
|
||||
{template.name}
|
||||
</h3>
|
||||
|
||||
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ const EmptyStateView: React.FC = () => {
|
|||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<div className="max-w-[800px] h-[calc(100vh-72px)] flex justify-center items-center mx-auto px-4 sm:px-6 pb-6">
|
||||
<Wrapper className="bg-white">
|
||||
<div className="max-w-[800px] h-[calc(100vh-72px)] font-syne flex justify-center items-center mx-auto px-4 sm:px-6 pb-6">
|
||||
<div className="text-center space-y-8">
|
||||
{/* Icon */}
|
||||
<div className="flex justify-center">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingState, Template } from "../types/index";
|
||||
import { TemplateLayoutsWithSettings } from "@/app/presentation-templates/utils";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
interface GenerateButtonProps {
|
||||
loadingState: LoadingState;
|
||||
|
|
@ -50,34 +51,14 @@ const GenerateButton: React.FC<GenerateButtonProps> = ({
|
|||
}
|
||||
onSubmit();
|
||||
}}
|
||||
className="bg-[#5146E5] w-full rounded-lg text-base sm:text-lg py-4 sm:py-6 font-instrument_sans font-semibold hover:bg-[#5146E5]/80 text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className=" w-full flex items-center gap-0.5 rounded-[58px] text-sm py-3 px-5 font-instrument_sans font-semibold text-[#101323] disabled:opacity-50 disabled:cursor-not-allowed font-syne"
|
||||
style={{
|
||||
background: "linear-gradient(270deg, #D5CAFC 2.4%, #E3D2EB 27.88%, #F4DCD3 69.23%, #FDE4C2 100%)",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className="mr-2"
|
||||
width="24"
|
||||
height="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 25 25"
|
||||
fill="none"
|
||||
>
|
||||
<g clipPath="url(#clip0_1960_939)">
|
||||
<path
|
||||
d="M21.217 9.57008L21.463 9.00408C21.8955 8.0028 22.6876 7.2 23.683 6.75408L24.442 6.41508C24.5341 6.37272 24.6121 6.30485 24.6668 6.21951C24.7214 6.13417 24.7505 6.03494 24.7505 5.93358C24.7505 5.83222 24.7214 5.73299 24.6668 5.64765C24.6121 5.56231 24.5341 5.49444 24.442 5.45208L23.725 5.13308C22.7046 4.67446 21.8989 3.84196 21.474 2.80708L21.221 2.19608C21.1838 2.10144 21.119 2.02018 21.035 1.96291C20.951 1.90563 20.8517 1.875 20.75 1.875C20.6483 1.875 20.549 1.90563 20.465 1.96291C20.381 2.02018 20.3162 2.10144 20.279 2.19608L20.026 2.80608C19.6015 3.84116 18.7962 4.67401 17.776 5.13308L17.058 5.45308C16.9662 5.49556 16.8885 5.56342 16.834 5.64865C16.7795 5.73389 16.7506 5.83293 16.7506 5.93408C16.7506 6.03523 16.7795 6.13428 16.834 6.21951C16.8885 6.30474 16.9662 6.3726 17.058 6.41508L17.818 6.75308C18.8132 7.19945 19.6049 8.00261 20.037 9.00408L20.283 9.57008C20.463 9.98408 21.036 9.98408 21.217 9.57008ZM6.55 16.8761H8.704L9.304 15.3761H12.196L12.796 16.8761H14.95L11.75 8.87608H9.75L6.55 16.8761ZM10.75 11.7611L11.396 13.3761H10.104L10.75 11.7611ZM15.75 16.8761V8.87608H17.75V16.8761H15.75ZM3.75 3.87608C3.48478 3.87608 3.23043 3.98144 3.04289 4.16897C2.85536 4.35651 2.75 4.61086 2.75 4.87608V20.8761C2.75 21.1413 2.85536 21.3957 3.04289 21.5832C3.23043 21.7707 3.48478 21.8761 3.75 21.8761H21.75C22.0152 21.8761 22.2696 21.7707 22.4571 21.5832C22.6446 21.3957 22.75 21.1413 22.75 20.8761V11.8761H20.75V19.8761H4.75V5.87608H14.75V3.87608H3.75Z"
|
||||
fill="white"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1960_939">
|
||||
<rect
|
||||
width="24"
|
||||
height="24"
|
||||
fill="white"
|
||||
transform="translate(0.75 0.876953)"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
{getButtonText()}
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ const OutlineContent: React.FC<OutlineContentProps> = ({
|
|||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<div className="space-y-6 font-instrument_sans">
|
||||
<div className="space-y-6 font-syne ">
|
||||
{isLoading && (!outlines || outlines.length === 0) && (
|
||||
<div className="flex items-center justify-center">
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-blue-200 bg-blue-50 text-blue-600 px-2 py-0.5 text-xs">
|
||||
|
|
@ -70,7 +70,7 @@ const OutlineContent: React.FC<OutlineContentProps> = ({
|
|||
</div> */}
|
||||
{/* Skeleton loading state */}
|
||||
{isLoading && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4 bg-white">
|
||||
{[...Array(6)].map((_, index) => (
|
||||
<div key={index} className="animate-pulse">
|
||||
<div className="flex items-start space-x-3 p-4 border rounded-lg bg-white">
|
||||
|
|
@ -91,41 +91,30 @@ const OutlineContent: React.FC<OutlineContentProps> = ({
|
|||
)}
|
||||
|
||||
{/* Outlines content */}
|
||||
|
||||
{outlines && outlines.length > 0 && (
|
||||
<div>
|
||||
<div className="bg-[#F9F8F8] min-h-[calc(100vh-16rem)] p-7 relative z-20 rounded-[20px] overflow-y-auto custom_scrollbar">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
{isStreaming ? (
|
||||
|
||||
outlines.map((item, index) => (
|
||||
<OutlineItem
|
||||
key={`slide-${index}`}
|
||||
index={index + 1}
|
||||
slideOutline={item}
|
||||
isStreaming={isStreaming}
|
||||
isActiveStreaming={activeSlideIndex === index}
|
||||
isStableStreaming={highestActiveIndex >= 0 && index < highestActiveIndex}
|
||||
/>
|
||||
))
|
||||
) :
|
||||
<SortableContext
|
||||
items={outlines?.map((item, index) => ({ id: `slide-${index}` })) || []}
|
||||
<SortableContext
|
||||
items={outlines.map((_, index) => `slide-${index}`)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{outlines?.map((item, index) => (
|
||||
{outlines.map((item, index) => (
|
||||
<OutlineItem
|
||||
key={`slide-${index}`}
|
||||
sortableId={`slide-${index}`}
|
||||
index={index + 1}
|
||||
slideOutline={item}
|
||||
isStreaming={isStreaming}
|
||||
isActiveStreaming={false}
|
||||
isStableStreaming={false}
|
||||
isActiveStreaming={activeSlideIndex === index}
|
||||
isStableStreaming={highestActiveIndex >= 0 && index < highestActiveIndex}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useSortable } from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import { Trash2 } from "lucide-react"
|
||||
import { GripHorizontal, Trash, Trash2 } from "lucide-react"
|
||||
import { RootState } from "@/store/store"
|
||||
import { useDispatch, useSelector } from "react-redux"
|
||||
import { deleteSlideOutline, setOutlines } from "@/store/slices/presentationGeneration"
|
||||
|
|
@ -11,6 +11,7 @@ import { marked } from "marked"
|
|||
|
||||
|
||||
interface OutlineItemProps {
|
||||
sortableId: string
|
||||
slideOutline: {
|
||||
content: string,
|
||||
},
|
||||
|
|
@ -21,6 +22,7 @@ interface OutlineItemProps {
|
|||
}
|
||||
|
||||
export function OutlineItem({
|
||||
sortableId,
|
||||
index,
|
||||
slideOutline,
|
||||
isStreaming,
|
||||
|
|
@ -45,7 +47,7 @@ export function OutlineItem({
|
|||
}
|
||||
}, [outlines.length]);
|
||||
|
||||
const handleSlideChange = (newOutline:any) => {
|
||||
const handleSlideChange = (newOutline: any) => {
|
||||
if (isStreaming) return;
|
||||
const newData = outlines?.map((each, idx) => {
|
||||
if (idx === index - 1) {
|
||||
|
|
@ -69,7 +71,7 @@ export function OutlineItem({
|
|||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: index })
|
||||
} = useSortable({ id: sortableId, disabled: isStreaming })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
|
|
@ -117,30 +119,34 @@ export function OutlineItem({
|
|||
}, [isStreaming, isActiveStreaming, isStableStreaming, slideOutline.content])
|
||||
|
||||
return (
|
||||
<div className="mb-2">
|
||||
{/* Main Title Row */}
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`mb-4 bg-white rounded-[12px] group shadow-sm p-10 relative font-syne transition-all duration-500 hover:shadow-[0_6.6px_13.2px_0_rgba(0,0,0,0.10)] ${isDragging ? "opacity-50" : ""}`}
|
||||
>
|
||||
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`flex items-start gap-2 md:gap-4 p-2 sm:pr-4 border border-black/10 bg-purple-100/10 rounded-[8px] ${isDragging ? "opacity-50" : ""}`}
|
||||
className="flex items-start gap-3 md:gap-4 rounded-[8px]"
|
||||
>
|
||||
{/* Drag Handle with Number - Make it smaller on mobile */}
|
||||
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="min-w-8 sm:min-w-10 w-10 sm:w-14 h-10 sm:h-14 bg-blue-400/10 rounded-[8px] flex items-center justify-center relative cursor-grab"
|
||||
className=" flex items-center justify-center relative cursor-grab"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-[2px]">
|
||||
<div className="w-[3px] h-[3px] bg-black/80 rounded-full" />
|
||||
<div className="w-[3px] h-[3px] bg-black/80 rounded-full" />
|
||||
<div className="w-[3px] h-[3px] bg-black/80 rounded-full" />
|
||||
<div className="w-[3px] h-[3px] bg-black/80 rounded-full" />
|
||||
</div>
|
||||
<span className="text-black/80 text-md sm:text-lg font-medium ml-1">{index}</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 10C12.5523 10 13 9.55228 13 9C13 8.44772 12.5523 8 12 8C11.4477 8 11 8.44772 11 9C11 9.55228 11.4477 10 12 10Z" fill="#191919" stroke="#191919" strokeWidth="0.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M19 10C19.5523 10 20 9.55228 20 9C20 8.44772 19.5523 8 19 8C18.4477 8 18 8.44772 18 9C18 9.55228 18.4477 10 19 10Z" fill="#191919" stroke="#191919" strokeWidth="0.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M5 10C5.55228 10 6 9.55228 6 9C6 8.44772 5.55228 8 5 8C4.44772 8 4 8.44772 4 9C4 9.55228 4.44772 10 5 10Z" fill="#191919" stroke="#191919" strokeWidth="0.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M12 16C12.5523 16 13 15.5523 13 15C13 14.4477 12.5523 14 12 14C11.4477 14 11 14.4477 11 15C11 15.5523 11.4477 16 12 16Z" fill="#191919" stroke="#191919" strokeWidth="0.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M19 16C19.5523 16 20 15.5523 20 15C20 14.4477 19.5523 14 19 14C18.4477 14 18 14.4477 18 15C18 15.5523 18.4477 16 19 16Z" fill="#191919" stroke="#191919" strokeWidth="0.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M5 16C5.55228 16 6 15.5523 6 15C6 14.4477 5.55228 14 5 14C4.44772 14 4 14.4477 4 15C4 15.5523 4.44772 16 5 16Z" fill="#191919" stroke="#191919" strokeWidth="0.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Main Title Input - Add onFocus handler */}
|
||||
|
||||
<div id={`outline-item-${index}`} className="flex flex-col basis-full gap-2">
|
||||
<p className="text-black w-fit text-[10px] font-medium bg-white border border-[#EDEEEF] rounded-[80px] px-2.5">slide {index}</p>
|
||||
{/* Editable Markdown Content */}
|
||||
{isStreaming ? (
|
||||
isActiveStreaming ? (
|
||||
|
|
@ -166,15 +172,15 @@ export function OutlineItem({
|
|||
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-1 sm:gap-2 items-center">
|
||||
|
||||
<div className="hidden group-hover:flex absolute -top-3 -right-3 gap-1 sm:gap-2 items-center">
|
||||
|
||||
<ToolTip content="Delete Slide">
|
||||
<button
|
||||
onClick={handleSlideDelete}
|
||||
className="p-1.5 sm:p-2 bg-gray-200/50 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
className="p-1.5 sm:p-2 bg-white shadow-md rounded-full transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 sm:w-5 sm:h-5 text-black/70" />
|
||||
<Trash className="w-4 h-4 text-black/70" />
|
||||
</button>
|
||||
</ToolTip>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { useOutlineManagement } from "../hooks/useOutlineManagement";
|
|||
import { usePresentationGeneration } from "../hooks/usePresentationGeneration";
|
||||
import TemplateSelection from "./TemplateSelection";
|
||||
import { TemplateLayoutsWithSettings } from "@/app/presentation-templates/utils";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
const OutlinePage: React.FC = () => {
|
||||
const { presentation_id, outlines } = useSelector(
|
||||
|
|
@ -39,7 +40,8 @@ const OutlinePage: React.FC = () => {
|
|||
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-72px)]">
|
||||
<div className=" font-syne pb-9">
|
||||
|
||||
<OverlayLoader
|
||||
show={loadingState.isLoading}
|
||||
text={loadingState.message}
|
||||
|
|
@ -47,16 +49,27 @@ const OutlinePage: React.FC = () => {
|
|||
duration={loadingState.duration}
|
||||
/>
|
||||
|
||||
<Wrapper className="h-full flex flex-col w-full">
|
||||
<div className="flex-grow overflow-y-hidden w-[1200px] mx-auto">
|
||||
<Wrapper className="h-full flex flex-col w-full relative px-5 sm:px-10 lg:px-20 ">
|
||||
<div className="flex-grow w-full hidden-scrollbar mx-auto ">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full flex flex-col">
|
||||
<TabsList className="grid w-[50%] mx-auto my-4 grid-cols-2">
|
||||
<TabsTrigger value={TABS.OUTLINE}>Outline & Content</TabsTrigger>
|
||||
<TabsTrigger value={TABS.LAYOUTS}>Select Template</TabsTrigger>
|
||||
<TabsList className="my-4 h-auto w-fit rounded-full border border-[#EDEEEF] bg-white p-1.5">
|
||||
<TabsTrigger
|
||||
value={TABS.OUTLINE}
|
||||
className="rounded-full px-5 py-2 text-xs font-medium text-[#2D2D2D] shadow-none data-[state=active]:bg-[#F4F3FF] data-[state=active]:text-[#7E3AF2] data-[state=active]:shadow-none"
|
||||
>
|
||||
Outline & Content
|
||||
</TabsTrigger>
|
||||
<Separator orientation="vertical" className="h-6 mx-1" />
|
||||
<TabsTrigger
|
||||
value={TABS.LAYOUTS}
|
||||
className="relative rounded-full px-5 py-2 text-xs font-medium text-[#2D2D2D] shadow-none data-[state=active]:bg-[#F4F3FF] data-[state=active]:text-[#7E3AF2] data-[state=active]:shadow-none"
|
||||
>
|
||||
Select Template
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex-grow w-full mx-auto">
|
||||
<TabsContent value={TABS.OUTLINE} className="h-[calc(100vh-16rem)] overflow-y-auto custom_scrollbar"
|
||||
<TabsContent value={TABS.OUTLINE} className="h-[calc(100vh-15rem)] overflow-y-auto hide-scrollbar"
|
||||
>
|
||||
<div>
|
||||
<OutlineContent
|
||||
|
|
@ -71,7 +84,7 @@ const OutlinePage: React.FC = () => {
|
|||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={TABS.LAYOUTS} className="h-[calc(100vh-16rem)] overflow-y-auto custom_scrollbar">
|
||||
<TabsContent value={TABS.LAYOUTS} className="h-[calc(100vh-16rem)] bg-white overflow-y-auto hide-scrollbar">
|
||||
<div>
|
||||
<TemplateSelection
|
||||
selectedTemplate={selectedTemplate}
|
||||
|
|
@ -81,11 +94,9 @@ const OutlinePage: React.FC = () => {
|
|||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
{/* Fixed Button */}
|
||||
|
||||
{/* Fixed Button */}
|
||||
<div className="py-4 border-t border-gray-200">
|
||||
<div className="max-w-[1200px] mx-auto">
|
||||
<div className="absolute bottom-[26px] right-[26px] z-50">
|
||||
<GenerateButton
|
||||
outlineCount={outlines.length}
|
||||
loadingState={loadingState}
|
||||
|
|
@ -95,6 +106,9 @@ const OutlinePage: React.FC = () => {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</Wrapper>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
"use client";
|
||||
import React, { useEffect } from "react";
|
||||
import React, { useEffect, useMemo, useCallback, memo } from "react";
|
||||
|
||||
import { TemplateLayoutsWithSettings } from "@/app/presentation-templates/utils";
|
||||
import { templates } from "@/app/presentation-templates";
|
||||
|
|
@ -8,19 +8,87 @@ import { TemplateWithData } from "@/app/presentation-templates/utils";
|
|||
import { CustomTemplates, useCustomTemplateSummaries } from "@/app/hooks/useCustomTemplates";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { CustomTemplateCard } from "./CustomTemplateCard";
|
||||
import CreateCustomTemplate from "../../(dashboard)/templates/components/CreateCustomTemplate";
|
||||
|
||||
// Memoized layout preview for built-in templates
|
||||
const BuiltInLayoutPreview = memo(({ layout, templateId, index }: {
|
||||
layout: TemplateWithData;
|
||||
templateId: string;
|
||||
index: number;
|
||||
}) => {
|
||||
const LayoutComponent = layout.component;
|
||||
return (
|
||||
<div
|
||||
className="relative bg-gray-100 font-syne border border-gray-200 overflow-hidden aspect-video rounded"
|
||||
style={{ contain: 'layout style paint' }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-transparent z-10" />
|
||||
<div
|
||||
className="transform scale-[0.12] origin-top-left"
|
||||
style={{ width: "833.33%", height: "833.33%" }}
|
||||
>
|
||||
<LayoutComponent data={layout.sampleData} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
BuiltInLayoutPreview.displayName = 'BuiltInLayoutPreview';
|
||||
|
||||
// Memoized built-in template card
|
||||
const BuiltInTemplateCard = memo(({ template, isSelected, onSelect }: {
|
||||
template: TemplateLayoutsWithSettings;
|
||||
isSelected: boolean;
|
||||
onSelect: (template: TemplateLayoutsWithSettings) => void;
|
||||
}) => {
|
||||
const previewLayouts = useMemo(() => template.layouts.slice(0, 4), [template.layouts]);
|
||||
const handleClick = useCallback(() => onSelect(template), [onSelect, template]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`${isSelected ? 'border-2 border-blue-500' : ''} cursor-pointer relative hover:shadow-lg transition-all duration-200 group overflow-hidden`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<span className="text-xs font-syne absolute top-2 flex gap-1 capitalize items-center left-2 rounded-[100px] px-2.5 py-1 bg-[#3A3A3AF5] text-white font-semibold z-40">
|
||||
Layouts- {template.layouts.length}
|
||||
</span>
|
||||
<img src="/card_bg.svg" alt="" className="absolute top-0 left-0 w-full h-full object-cover" />
|
||||
<div className="p-5">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{previewLayouts.map((layout: TemplateWithData, index: number) => (
|
||||
<BuiltInLayoutPreview
|
||||
key={`${template.id}-preview-${index}`}
|
||||
layout={layout}
|
||||
templateId={template.id}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-5 bg-white border-t border-[#EDEEEF] relative z-40">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-gray-900 capitalize font-syne">
|
||||
{template.name}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-600 line-clamp-2 font-syne">
|
||||
{template.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
BuiltInTemplateCard.displayName = 'BuiltInTemplateCard';
|
||||
|
||||
interface TemplateSelectionProps {
|
||||
selectedTemplate: (TemplateLayoutsWithSettings | string) | null;
|
||||
onSelectTemplate: (template: TemplateLayoutsWithSettings | string) => void;
|
||||
}
|
||||
|
||||
const TemplateSelection: React.FC<TemplateSelectionProps> = ({
|
||||
const TemplateSelection: React.FC<TemplateSelectionProps> = memo(({
|
||||
selectedTemplate,
|
||||
onSelectTemplate
|
||||
}) => {
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
const existingScript = document.querySelector(
|
||||
'script[src*="tailwindcss.com"]'
|
||||
);
|
||||
|
|
@ -30,107 +98,99 @@ const TemplateSelection: React.FC<TemplateSelectionProps> = ({
|
|||
script.async = true;
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
}, []);
|
||||
|
||||
const { templates: customTemplates, loading: customLoading } = useCustomTemplateSummaries();
|
||||
|
||||
// Stable callback for custom template selection
|
||||
const handleCustomSelect = useCallback(
|
||||
(template: TemplateLayoutsWithSettings | string) => onSelectTemplate(template),
|
||||
[onSelectTemplate]
|
||||
);
|
||||
|
||||
// Stable callback for built-in template selection
|
||||
const handleBuiltInSelect = useCallback(
|
||||
(template: TemplateLayoutsWithSettings) => onSelectTemplate(template),
|
||||
[onSelectTemplate]
|
||||
);
|
||||
|
||||
// Derive the selected custom template id only when selectedTemplate changes
|
||||
const selectedCustomId = useMemo(
|
||||
() => (typeof selectedTemplate === 'string' ? selectedTemplate : null),
|
||||
[selectedTemplate]
|
||||
);
|
||||
|
||||
// Derive the selected built-in template id only when selectedTemplate changes
|
||||
const selectedBuiltInId = useMemo(
|
||||
() => (typeof selectedTemplate !== 'string' ? selectedTemplate?.id ?? null : null),
|
||||
[selectedTemplate]
|
||||
);
|
||||
|
||||
// Memoize the custom templates section
|
||||
const customTemplateCards = useMemo(() => {
|
||||
if (customLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 font-syne">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600 font-syne" />
|
||||
<span className="ml-3 text-gray-600">Loading custom templates...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (customTemplates.length === 0) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
|
||||
<CreateCustomTemplate />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
{customTemplates.map((template: CustomTemplates) => (
|
||||
<CustomTemplateCard
|
||||
key={template.id}
|
||||
template={template}
|
||||
onSelectTemplate={handleCustomSelect}
|
||||
selectedTemplate={selectedCustomId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}, [customLoading, customTemplates, handleCustomSelect, selectedCustomId]);
|
||||
|
||||
// Memoize the built-in templates list
|
||||
const builtInTemplateCards = useMemo(
|
||||
() =>
|
||||
templates.map((template: TemplateLayoutsWithSettings) => (
|
||||
<BuiltInTemplateCard
|
||||
key={template.id}
|
||||
template={template}
|
||||
isSelected={selectedBuiltInId === template.id}
|
||||
onSelect={handleBuiltInSelect}
|
||||
/>
|
||||
)),
|
||||
[selectedBuiltInId, handleBuiltInSelect]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-8 mb-4">
|
||||
{/* In Built Templates */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">In Built Templates</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{templates.map((template: TemplateLayoutsWithSettings) => {
|
||||
const previewLayouts = template.layouts.slice(0, 4);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={template.id}
|
||||
className={`${typeof selectedTemplate !== 'string' && selectedTemplate?.id === template.id ? 'border-2 border-blue-500' : ''} cursor-pointer hover:shadow-lg transition-all duration-200 group overflow-hidden relative`}
|
||||
onClick={() => onSelectTemplate(template)}
|
||||
>
|
||||
<div className="p-5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-xl font-bold text-gray-900 capitalize">
|
||||
{template.name}
|
||||
</h3>
|
||||
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 mb-4 line-clamp-2">
|
||||
{template.description}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{previewLayouts.map((layout: TemplateWithData, index: number) => {
|
||||
const LayoutComponent = layout.component;
|
||||
return (
|
||||
<div
|
||||
key={`${template.id}-preview-${index}`}
|
||||
className="relative bg-gray-100 border border-gray-200 overflow-hidden aspect-video rounded"
|
||||
style={{ contain: 'layout style paint' }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-transparent z-10" />
|
||||
<div
|
||||
className="transform scale-[0.2] flex justify-center items-center origin-top-left w-[500%] h-[500%]"
|
||||
style={{ transform: 'scale(0.2) translateZ(0)', backfaceVisibility: 'hidden' }}
|
||||
>
|
||||
<LayoutComponent data={layout.sampleData} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{typeof selectedTemplate !== 'string' && selectedTemplate?.id === template.id && (
|
||||
<div className="absolute top-0 right-0 bg-blue-500 text-white px-2 py-1 rounded-bl-lg">
|
||||
Selected
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-[30px] mb-4">
|
||||
{/* Custom AI Templates */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Custom AI Templates</h3>
|
||||
<h3 className="text-base font-semibold text-gray-900 font-syne">Custom</h3>
|
||||
</div>
|
||||
{customTemplateCards}
|
||||
</div>
|
||||
{/* In Built Templates */}
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-3 font-syne">In Built</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{builtInTemplateCards}
|
||||
</div>
|
||||
{customLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
|
||||
<span className="ml-3 text-gray-600">Loading custom templates...</span>
|
||||
</div>
|
||||
) : customTemplates.length === 0 ? (
|
||||
<Card className="p-8 text-center">
|
||||
<p className="text-gray-500">No custom templates yet.</p>
|
||||
<p className="text-sm text-gray-400 mt-2">
|
||||
Custom templates you create will appear here.
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{customTemplates.map((template: CustomTemplates) => (
|
||||
|
||||
<CustomTemplateCard
|
||||
key={template.id}
|
||||
template={template}
|
||||
onSelectTemplate={onSelectTemplate}
|
||||
selectedTemplate={typeof selectedTemplate === 'string' ? selectedTemplate : null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
TemplateSelection.displayName = 'TemplateSelection';
|
||||
|
||||
export default TemplateSelection;
|
||||
export default TemplateSelection;
|
||||
|
|
|
|||
|
|
@ -11,12 +11,16 @@ export const useOutlineManagement = (outlines: { content: string }[] | null) =>
|
|||
|
||||
if (!active || !over || !outlines) return;
|
||||
|
||||
if (active.id !== over.id) {
|
||||
const oldIndex = outlines.findIndex((item) => item.content === active.id);
|
||||
const newIndex = outlines.findIndex((item) => item.content === over.id);
|
||||
const reorderedArray = arrayMove(outlines, oldIndex, newIndex);
|
||||
dispatch(setOutlines(reorderedArray));
|
||||
}
|
||||
if (active.id === over.id) return;
|
||||
|
||||
const oldIndex = Number(String(active.id).replace("slide-", ""));
|
||||
const newIndex = Number(String(over.id).replace("slide-", ""));
|
||||
|
||||
if (Number.isNaN(oldIndex) || Number.isNaN(newIndex)) return;
|
||||
if (oldIndex < 0 || newIndex < 0 || oldIndex >= outlines.length || newIndex >= outlines.length) return;
|
||||
|
||||
const reorderedArray = arrayMove(outlines, oldIndex, newIndex);
|
||||
dispatch(setOutlines(reorderedArray));
|
||||
}, [outlines, dispatch]);
|
||||
|
||||
const handleAddSlide = useCallback(() => {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { toast } from "sonner";
|
|||
import { setOutlines } from "@/store/slices/presentationGeneration";
|
||||
import { jsonrepair } from "jsonrepair";
|
||||
import { RootState } from "@/store/store";
|
||||
import { getApiUrl } from "@/utils/api";
|
||||
import { getFastAPIUrl } from "@/utils/api";
|
||||
|
||||
|
||||
|
||||
|
|
@ -29,9 +29,7 @@ export const useOutlineStreaming = (presentationId: string | null) => {
|
|||
setIsStreaming(true)
|
||||
setIsLoading(true)
|
||||
try {
|
||||
eventSource = new EventSource(
|
||||
getApiUrl(`api/v1/ppt/outlines/stream/${presentationId}`)
|
||||
);
|
||||
eventSource = new EventSource(`${getFastAPIUrl()}/api/v1/ppt/outlines/stream/${presentationId}`) ;
|
||||
|
||||
eventSource.addEventListener("response", (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ export const usePresentationGeneration = (
|
|||
layout = {
|
||||
name: selectedTemplate.id,
|
||||
ordered: false,
|
||||
slides: selectedTemplate.layouts.map((layoutItem) => ({
|
||||
slides: selectedTemplate.layouts.map((layoutItem: any) => ({
|
||||
id: layoutItem.layoutId,
|
||||
name: layoutItem.layoutName,
|
||||
description: layoutItem.layoutDescription,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react'
|
||||
import Header from '@/app/(presentation-generator)/dashboard/components/Header'
|
||||
import Header from '@/app/(presentation-generator)/(dashboard)/dashboard/components/Header'
|
||||
import { Metadata } from 'next'
|
||||
import OutlinePage from './components/OutlinePage'
|
||||
export const metadata: Metadata = {
|
||||
|
|
@ -25,6 +25,15 @@ const page = () => {
|
|||
return (
|
||||
<div className='relative min-h-screen'>
|
||||
<Header />
|
||||
<div
|
||||
className='fixed z-[-10] bottom-5 left-1/2 -translate-x-1/2 w-full h-full'
|
||||
style={{
|
||||
height: "341px",
|
||||
width: "86%",
|
||||
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%)',
|
||||
}}
|
||||
/>
|
||||
<OutlinePage />
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import { setupImageUrlConverter } from "@/utils/image-url-converter";
|
|||
|
||||
|
||||
import { V1ContentRender } from "../components/V1ContentRender";
|
||||
import { useFontLoader } from "../hooks/useFontLoad";
|
||||
import { Theme } from "../services/api/types";
|
||||
|
||||
|
||||
|
||||
|
|
@ -42,13 +44,13 @@ const PresentationPage = ({ presentation_id }: { presentation_id: string }) => {
|
|||
}
|
||||
}
|
||||
}, [presentationData]);
|
||||
|
||||
// Setup image URL converter for Docker/browser compatibility
|
||||
|
||||
// Ensure /app_data and /static image paths resolve through FastAPI in Electron.
|
||||
useEffect(() => {
|
||||
const observer = setupImageUrlConverter();
|
||||
return () => observer?.disconnect();
|
||||
}, []);
|
||||
|
||||
|
||||
// Function to fetch the slides
|
||||
useEffect(() => {
|
||||
fetchUserSlides();
|
||||
|
|
@ -60,6 +62,9 @@ const PresentationPage = ({ presentation_id }: { presentation_id: string }) => {
|
|||
const data = await DashboardApi.getPresentation(presentation_id);
|
||||
dispatch(setPresentationData(data));
|
||||
setContentLoading(false);
|
||||
if (data?.theme) {
|
||||
applyTheme(data.theme);
|
||||
}
|
||||
} catch (error) {
|
||||
setError(true);
|
||||
toast.error("Failed to load presentation");
|
||||
|
|
@ -68,6 +73,43 @@ const PresentationPage = ({ presentation_id }: { presentation_id: string }) => {
|
|||
}
|
||||
};
|
||||
|
||||
const applyTheme = async (theme: Theme) => {
|
||||
const element = document.getElementById('presentation-slides-wrapper')
|
||||
if (!element) return;
|
||||
if (!theme || !theme.data) { return; }
|
||||
if (!theme.data.colors['graph_0']) { return; }
|
||||
const cssVariables = {
|
||||
'--primary-color': theme.data.colors['primary'],
|
||||
'--background-color': theme.data.colors['background'],
|
||||
'--card-color': theme.data.colors['card'],
|
||||
'--stroke': theme.data.colors['stroke'],
|
||||
'--primary-text': theme.data.colors['primary_text'],
|
||||
'--background-text': theme.data.colors['background_text'],
|
||||
'--graph-0': theme.data.colors['graph_0'],
|
||||
'--graph-1': theme.data.colors['graph_1'],
|
||||
'--graph-2': theme.data.colors['graph_2'],
|
||||
'--graph-3': theme.data.colors['graph_3'],
|
||||
'--graph-4': theme.data.colors['graph_4'],
|
||||
'--graph-5': theme.data.colors['graph_5'],
|
||||
'--graph-6': theme.data.colors['graph_6'],
|
||||
'--graph-7': theme.data.colors['graph_7'],
|
||||
'--graph-8': theme.data.colors['graph_8'],
|
||||
'--graph-9': theme.data.colors['graph_9'],
|
||||
}
|
||||
|
||||
Object.entries(cssVariables).forEach(([key, value]) => {
|
||||
element.style.setProperty(key, value)
|
||||
})
|
||||
useFontLoader({ [theme.data.fonts.textFont.name]: theme.data.fonts.textFont.url })
|
||||
|
||||
// Apply fonts to preview container
|
||||
element.style.setProperty('font-family', `"${theme.data.fonts.textFont.name}"`)
|
||||
element.style.setProperty('--heading-font-family', `"${theme.data.fonts.textFont.name}"`)
|
||||
element.style.setProperty('--body-font-family', `"${theme.data.fonts.textFont.name}"`)
|
||||
// Update the Presentation content with theme
|
||||
}
|
||||
|
||||
|
||||
// Regular view
|
||||
return (
|
||||
<div className="flex overflow-hidden flex-col">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,135 @@
|
|||
'use client'
|
||||
import React, { useEffect, useState, memo, useCallback } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { addNewSlide } from "@/store/slices/presentationGeneration";
|
||||
import { Loader2, X } from "lucide-react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
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>
|
||||
<X
|
||||
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>
|
||||
<X
|
||||
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,320 @@
|
|||
"use client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Play,
|
||||
Loader2,
|
||||
Redo2,
|
||||
Undo2,
|
||||
RotateCcw,
|
||||
ArrowRightFromLine,
|
||||
|
||||
ArrowUpRight,
|
||||
|
||||
} from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { PresentationGenerationApi } from "../../services/api/presentation-generation";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
|
||||
|
||||
import { RootState } from "@/store/store";
|
||||
import { toast } from "sonner";
|
||||
|
||||
|
||||
import { PptxPresentationModel } from "@/types/pptx_models";
|
||||
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
|
||||
import { usePresentationUndoRedo } from "../hooks/PresentationUndoRedo";
|
||||
import ToolTip from "@/components/ToolTip";
|
||||
import { clearPresentationData } from "@/store/slices/presentationGeneration";
|
||||
import { clearHistory } from "@/store/slices/undoRedoSlice";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import ThemeSelector from "./ThemeSelector";
|
||||
import { DEFAULT_THEMES } from "../../(dashboard)/theme/components/ThemePanel/constants";
|
||||
import ThemeApi from "../../services/api/theme";
|
||||
import { Theme } from "../../services/api/types";
|
||||
import MarkdownRenderer from "@/components/MarkDownRender";
|
||||
|
||||
const PresentationHeader = ({
|
||||
presentation_id,
|
||||
isPresentationSaving,
|
||||
currentSlide,
|
||||
}: {
|
||||
presentation_id: string;
|
||||
isPresentationSaving: boolean;
|
||||
currentSlide?: number;
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [themes, setThemes] = useState<Theme[]>([]);
|
||||
|
||||
const pathname = usePathname();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
|
||||
const { presentationData, isStreaming } = useSelector(
|
||||
(state: RootState) => state.presentationGeneration
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const [customThemes] = await Promise.all([
|
||||
ThemeApi.getThemes(),
|
||||
]);
|
||||
setThemes([...customThemes, ...DEFAULT_THEMES]);
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || "Failed to load themes");
|
||||
}
|
||||
};
|
||||
if (themes.length === 0) {
|
||||
load();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { onUndo, onRedo, canUndo, canRedo } = usePresentationUndoRedo();
|
||||
|
||||
const get_presentation_pptx_model = async (id: string): Promise<PptxPresentationModel> => {
|
||||
const response = await fetch(`/api/presentation_to_pptx_model?id=${id}`);
|
||||
const pptx_model = await response.json();
|
||||
return pptx_model;
|
||||
};
|
||||
|
||||
const exportViaIpc = async (format: "pptx" | "pdf"): Promise<boolean> => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
if (!(window as any).electron?.exportPresentation) return false;
|
||||
trackEvent(
|
||||
format === "pptx"
|
||||
? MixpanelEvent.Header_ExportAsPPTX_API_Call
|
||||
: MixpanelEvent.Header_ExportAsPDF_API_Call
|
||||
);
|
||||
const result = await (window as any).electron.exportPresentation(
|
||||
presentation_id,
|
||||
presentationData?.title || 'presentation',
|
||||
format
|
||||
);
|
||||
if (!result?.success) {
|
||||
throw new Error(result?.message || 'Export failed');
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleExportPptx = async () => {
|
||||
if (isStreaming) return;
|
||||
|
||||
try {
|
||||
toast.info("Exporting PPTX...");
|
||||
setIsExporting(true);
|
||||
// Save the presentation data before exporting
|
||||
trackEvent(MixpanelEvent.Header_UpdatePresentationContent_API_Call);
|
||||
await PresentationGenerationApi.updatePresentationContent(presentationData);
|
||||
|
||||
if (await exportViaIpc("pptx")) {
|
||||
toast.success("PPTX exported successfully!");
|
||||
return;
|
||||
}
|
||||
|
||||
trackEvent(MixpanelEvent.Header_GetPptxModel_API_Call);
|
||||
const pptx_model = await get_presentation_pptx_model(presentation_id);
|
||||
if (!pptx_model) {
|
||||
throw new Error("Failed to get presentation PPTX model");
|
||||
}
|
||||
trackEvent(MixpanelEvent.Header_ExportAsPPTX_API_Call);
|
||||
const pptx_path = await PresentationGenerationApi.exportAsPPTX(pptx_model);
|
||||
if (pptx_path) {
|
||||
// window.open(pptx_path, '_self');
|
||||
downloadLink(pptx_path);
|
||||
} else {
|
||||
throw new Error("No path returned from export");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Export failed:", error);
|
||||
toast.error("Having trouble exporting!", {
|
||||
description:
|
||||
"We are having trouble exporting your presentation. Please try again.",
|
||||
});
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportPdf = async () => {
|
||||
if (isStreaming) return;
|
||||
|
||||
try {
|
||||
toast.info("Exporting PDF...");
|
||||
setIsExporting(true);
|
||||
// Save the presentation data before exporting
|
||||
trackEvent(MixpanelEvent.Header_UpdatePresentationContent_API_Call);
|
||||
await PresentationGenerationApi.updatePresentationContent(presentationData);
|
||||
|
||||
trackEvent(MixpanelEvent.Header_ExportAsPDF_API_Call);
|
||||
if (await exportViaIpc("pdf")) {
|
||||
toast.success("PDF exported successfully!");
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/api/export-as-pdf', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
id: presentation_id,
|
||||
title: presentationData?.title,
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const { path: pdfPath } = await response.json();
|
||||
// window.open(pdfPath, '_blank');
|
||||
downloadLink(pdfPath);
|
||||
} else {
|
||||
throw new Error("Failed to export PDF");
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error("Having trouble exporting!", {
|
||||
description:
|
||||
"We are having trouble exporting your presentation. Please try again.",
|
||||
});
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
const handleReGenerate = () => {
|
||||
dispatch(clearPresentationData());
|
||||
dispatch(clearHistory())
|
||||
trackEvent(MixpanelEvent.Header_ReGenerate_Button_Clicked, { pathname });
|
||||
router.push(`/presentation?id=${presentation_id}&stream=true`);
|
||||
};
|
||||
const downloadLink = (path: string) => {
|
||||
// if we have popup access give direct download if not redirect to the path
|
||||
if (window.opener) {
|
||||
window.open(path, '_blank');
|
||||
} else {
|
||||
const link = document.createElement('a');
|
||||
link.href = path;
|
||||
link.download = path.split('/').pop() || 'download';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
}
|
||||
};
|
||||
|
||||
const ExportOptions = ({ mobile }: { mobile: boolean }) => (
|
||||
<div className={` rounded-[18px] max-md:mt-4 ${mobile ? "" : "bg-white"} p-5`}>
|
||||
<p className="text-sm font-medium text-[#19001F]">Export as</p>
|
||||
<div className="my-[18px] h-[1px] bg-[#E8E8E8]" />
|
||||
<div className="space-y-3">
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
trackEvent(MixpanelEvent.Header_Export_PDF_Button_Clicked, { pathname });
|
||||
handleExportPdf();
|
||||
setOpen(false);
|
||||
}}
|
||||
variant="ghost"
|
||||
className={` rounded-none px-0 w-full text-xs flex justify-start text-black hover:bg-transparent ${mobile ? "bg-white py-6 border-none rounded-lg" : ""}`} >
|
||||
|
||||
PDF
|
||||
<ArrowUpRight className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
trackEvent(MixpanelEvent.Header_Export_PPTX_Button_Clicked, { pathname });
|
||||
handleExportPptx();
|
||||
setOpen(false);
|
||||
}}
|
||||
variant="ghost"
|
||||
className={`w-full flex px-0 justify-start text-xs text-black hover:bg-transparent ${mobile ? "bg-white py-6" : ""}`}
|
||||
>
|
||||
|
||||
PPTX
|
||||
<ArrowUpRight className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="py-7 sticky top-0 bg-white z-50 mb-[17px] font-syne flex justify-between items-center">
|
||||
<h2 className="text-lg text-[#101323] font-unbounded "><MarkdownRenderer content={presentationData?.title || "Presentation"} className="mb-0 w-[600px] truncate text-sm text-[#101323] " /></h2>
|
||||
<div className="flex items-center gap-2.5">
|
||||
|
||||
{isPresentationSaving && <div className="flex items-center gap-2">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
</div>}
|
||||
<ThemeSelector current_theme={presentationData?.theme || {}} themes={themes} />
|
||||
|
||||
<div className="flex items-center gap-2 bg-[#F6F6F9] px-3.5 h-[38px] border border-[#EDECEC] rounded-[80px]">
|
||||
|
||||
<ToolTip content="Regenerate Presentation">
|
||||
<button onClick={handleReGenerate} className="group">
|
||||
<RotateCcw className="w-3.5 h-3.5 text-[#101323] group-hover:text-[#5141e5] duration-300" />
|
||||
</button>
|
||||
</ToolTip>
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<ToolTip content="Undo">
|
||||
<button disabled={!canUndo} className=" disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer group" onClick={() => {
|
||||
onUndo();
|
||||
}}>
|
||||
|
||||
<Undo2 className="w-3.5 h-3.5 text-[#101323] group-hover:text-[#5141e5] duration-300" />
|
||||
|
||||
</button>
|
||||
</ToolTip>
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<ToolTip content="Redo">
|
||||
|
||||
<button disabled={!canRedo} className=" disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer group" onClick={() => {
|
||||
|
||||
onRedo();
|
||||
}}>
|
||||
<Redo2 className="w-3.5 h-3.5 text-[#101323] group-hover:text-[#5141e5] duration-300" />
|
||||
|
||||
</button>
|
||||
</ToolTip>
|
||||
<Separator orientation="vertical" className="h-4 w-[2px]" />
|
||||
<ToolTip content="Present">
|
||||
<button
|
||||
onClick={() => {
|
||||
const to = `?id=${presentation_id}&mode=present&slide=${currentSlide || 0}`;
|
||||
trackEvent(MixpanelEvent.Navigation, { from: pathname, to });
|
||||
router.push(to);
|
||||
}}
|
||||
disabled={!presentationData?.slides || presentationData?.slides.length === 0} className="cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed group">
|
||||
<Play className="w-3.5 h-3.5 text-[#101323] group-hover:text-[#5141e5] duration-300" />
|
||||
</button>
|
||||
</ToolTip>
|
||||
</div>
|
||||
|
||||
<Popover open={open} onOpenChange={setOpen} >
|
||||
<PopoverTrigger asChild>
|
||||
<button className="flex items-center gap-[7px] px-[18px] py-[11px] rounded-[53px] text-sm font-semibold text-[#101323]"
|
||||
style={{
|
||||
background: "linear-gradient(270deg, #D5CAFC 2.4%, #E3D2EB 27.88%, #F4DCD3 69.23%, #FDE4C2 100%)",
|
||||
}}
|
||||
disabled={isExporting}
|
||||
>
|
||||
{isExporting ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : "Export"} <ArrowRightFromLine className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-[200px] rounded-[18px] space-y-2 p-0 ">
|
||||
<ExportOptions mobile={false} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PresentationHeader;
|
||||
|
|
@ -0,0 +1,306 @@
|
|||
"use client";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
X,
|
||||
Minimize2,
|
||||
Maximize2,
|
||||
StickyNote,
|
||||
EyeOff,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Slide } from "../../types/slide";
|
||||
import { V1ContentRender } from "../../components/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 [showSpeakerNotes, setShowSpeakerNotes] = useState(true);
|
||||
const currentSpeakerNote = useMemo(
|
||||
() => slides[currentSlide]?.speaker_note?.trim() || "",
|
||||
[slides, currentSlide]
|
||||
);
|
||||
|
||||
|
||||
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;
|
||||
case "n":
|
||||
case "N":
|
||||
setShowSpeakerNotes((prev) => !prev);
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Centered 16:9 stage for consistent alignment in normal + fullscreen modes */}
|
||||
<div className={`flex-1 min-h-0 flex items-center justify-center ${isFullscreen ? "px-6 py-8 md:px-10 md:py-12" : "p-8"}`}>
|
||||
<div
|
||||
className="relative rounded-sm font-inter"
|
||||
style={{
|
||||
aspectRatio: "16 / 9",
|
||||
width: isFullscreen
|
||||
? "min(90vw, calc(88vh * 16 / 9))"
|
||||
: "min(calc(100vw - 4rem), calc((100vh - 4rem) * 16 / 9))",
|
||||
maxHeight: isFullscreen ? "88vh" : "calc(100vh - 4rem)",
|
||||
}}
|
||||
>
|
||||
{slides.length > 0 && slides.map((slide, index) => (
|
||||
<div
|
||||
key={slide.id}
|
||||
className={index === currentSlide ? "h-full w-full" : "hidden h-full w-full"}
|
||||
>
|
||||
<V1ContentRender slide={slide} isEditMode={true} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentSpeakerNote && (
|
||||
<div className="presentation-controls absolute bottom-4 right-4 z-50">
|
||||
{showSpeakerNotes ? (
|
||||
<div className="w-[360px] max-w-[50vw] rounded-xl border border-black/10 bg-white/95 shadow-xl backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between border-b border-black/10 px-3 py-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-gray-800">
|
||||
<StickyNote className="h-4 w-4" />
|
||||
Speaker notes
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowSpeakerNotes(false);
|
||||
}}
|
||||
className="h-8 px-2 text-gray-600 hover:bg-black/5 hover:text-gray-800"
|
||||
>
|
||||
<EyeOff className="mr-1 h-4 w-4" />
|
||||
Hide
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-[28vh] overflow-auto whitespace-pre-wrap px-3 py-2 text-sm text-gray-700">
|
||||
{currentSpeakerNote}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowSpeakerNotes(true);
|
||||
}}
|
||||
className="h-9 rounded-full border border-black/10 bg-white/95 px-3 text-gray-800 shadow-md hover:bg-white"
|
||||
>
|
||||
<StickyNote className="mr-2 h-4 w-4" />
|
||||
Show notes
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PresentationMode;
|
||||
|
|
@ -1,29 +1,29 @@
|
|||
"use client";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "@/store/store";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import PresentationMode from "../../components/PresentationMode";
|
||||
import PresentationMode from "./PresentationMode";
|
||||
import SidePanel from "./SidePanel";
|
||||
import SlideContent from "./SlideContent";
|
||||
import Header from "./Header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
|
||||
import { AlertCircle, Loader2 } from "lucide-react";
|
||||
import Help from "./Help";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import {
|
||||
usePresentationStreaming,
|
||||
usePresentationData,
|
||||
usePresentationNavigation,
|
||||
useAutoSave,
|
||||
} from "../hooks";
|
||||
import { useEffect } from "react";
|
||||
import { PresentationPageProps } from "../types";
|
||||
import LoadingState from "./LoadingState";
|
||||
import { setupImageUrlConverter } from "@/utils/image-url-converter";
|
||||
|
||||
import { useFontLoader } from "../../hooks/useFontLoader";
|
||||
import { usePresentationUndoRedo } from "../hooks/PresentationUndoRedo";
|
||||
import PresentationHeader from "./PresentationHeader";
|
||||
|
||||
const PresentationPage: React.FC<PresentationPageProps> = ({
|
||||
presentation_id,
|
||||
}) => {
|
||||
|
|
@ -33,9 +33,8 @@ const PresentationPage: React.FC<PresentationPageProps> = ({
|
|||
const [selectedSlide, setSelectedSlide] = useState(0);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const [isMobilePanelOpen, setIsMobilePanelOpen] = useState(false);
|
||||
|
||||
// Setup image URL converter for Docker/browser compatibility
|
||||
|
||||
// Ensure /app_data and /static image paths resolve through FastAPI in Electron.
|
||||
useEffect(() => {
|
||||
const observer = setupImageUrlConverter();
|
||||
return () => observer?.disconnect();
|
||||
|
|
@ -88,7 +87,6 @@ const PresentationPage: React.FC<PresentationPageProps> = ({
|
|||
handleSlideChange(newSlide, presentationData);
|
||||
};
|
||||
|
||||
|
||||
// useEffect(() => {
|
||||
// if(!loading && !isStreaming && presentationData?.slides && presentationData?.slides.length > 0){
|
||||
// const presentation_id = presentationData?.slides[0].layout.split(":")[0].split("custom-")[1];
|
||||
|
|
@ -113,7 +111,7 @@ const PresentationPage: React.FC<PresentationPageProps> = ({
|
|||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-screen bg-gray-100">
|
||||
<div className="flex flex-col items-center justify-center h-screen bg-gray-100 font-syne">
|
||||
<div
|
||||
className="bg-white border border-red-300 text-red-700 px-6 py-8 rounded-lg shadow-lg flex flex-col items-center"
|
||||
role="alert"
|
||||
|
|
@ -130,63 +128,65 @@ const PresentationPage: React.FC<PresentationPageProps> = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex overflow-hidden flex-col">
|
||||
<div className="fixed right-6 top-[5.2rem] z-50">
|
||||
{isSaving && <Loader2 className="w-6 h-6 animate-spin text-blue-500" />}
|
||||
</div>
|
||||
|
||||
<Header presentation_id={presentation_id} currentSlide={selectedSlide} />
|
||||
<Help />
|
||||
|
||||
<div className="h-screen overflow-hidden font-syne ">
|
||||
<div
|
||||
style={{
|
||||
background: "#c8c7c9",
|
||||
background: "#ffffff",
|
||||
}}
|
||||
className="flex flex-1 relative pt-6"
|
||||
className="flex gap-6 relative "
|
||||
>
|
||||
<SidePanel
|
||||
selectedSlide={selectedSlide}
|
||||
onSlideClick={handleSlideClick}
|
||||
loading={loading}
|
||||
isMobilePanelOpen={isMobilePanelOpen}
|
||||
setIsMobilePanelOpen={setIsMobilePanelOpen}
|
||||
/>
|
||||
<div className="w-[200px]">
|
||||
<SidePanel
|
||||
selectedSlide={selectedSlide}
|
||||
onSlideClick={handleSlideClick}
|
||||
presentationId={presentation_id}
|
||||
loading={loading}
|
||||
|
||||
<div className="flex-1 h-[calc(100vh-100px)] overflow-y-auto">
|
||||
/>
|
||||
</div>
|
||||
<div className=" w-full h-[calc(100vh-20px)] hide-scrollbar pr-[25px] overflow-y-auto">
|
||||
<PresentationHeader presentation_id={presentation_id} isPresentationSaving={isSaving} currentSlide={selectedSlide} />
|
||||
<div
|
||||
id="presentation-slides-wrapper"
|
||||
className="mx-auto flex flex-col items-center overflow-hidden justify-center p-2 sm:p-6 pt-0"
|
||||
style={{
|
||||
background: "rgba(255, 255, 255, 0.10)",
|
||||
boxShadow: "0 0 20.01px 0 rgba(122, 90, 248, 0.16) inset",
|
||||
}}
|
||||
className="p-6 rounded-[20px] flex flex-col items-center overflow-hidden justify-center border border-[#EDECEC] "
|
||||
>
|
||||
{!presentationData ||
|
||||
loading ||
|
||||
!presentationData?.slides ||
|
||||
presentationData?.slides.length === 0 ? (
|
||||
<div className="relative w-full h-[calc(100vh-120px)] mx-auto">
|
||||
<div className="">
|
||||
{Array.from({ length: 2 }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={index}
|
||||
className="aspect-video bg-gray-400 my-4 w-full mx-auto max-w-[1280px]"
|
||||
/>
|
||||
))}
|
||||
<div className="w-full max-w-[1280px] h-full">
|
||||
|
||||
{!presentationData ||
|
||||
loading ||
|
||||
!presentationData?.slides ||
|
||||
presentationData?.slides.length === 0 ? (
|
||||
<div className="relative w-full h-[calc(100vh-120px)] mx-auto">
|
||||
<div className="">
|
||||
{Array.from({ length: 2 }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={index}
|
||||
className="aspect-video bg-gray-400 my-4 w-full mx-auto "
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{stream && <LoadingState />}
|
||||
</div>
|
||||
{stream && <LoadingState />}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{presentationData &&
|
||||
presentationData.slides &&
|
||||
presentationData.slides.length > 0 &&
|
||||
presentationData.slides.map((slide: any, index: number) => (
|
||||
<SlideContent
|
||||
key={`${slide.type}-${index}-${slide.index}`}
|
||||
slide={slide}
|
||||
index={index}
|
||||
presentationId={presentation_id}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
) : (
|
||||
<>
|
||||
{presentationData &&
|
||||
presentationData.slides &&
|
||||
presentationData.slides.length > 0 &&
|
||||
presentationData.slides.map((slide: any, index: number) => (
|
||||
<SlideContent
|
||||
key={`${slide.type}-${index}-${slide.index}`}
|
||||
slide={slide}
|
||||
index={index}
|
||||
presentationId={presentation_id}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
"use client";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { LayoutList, ListTree, PanelRightOpen, X } from "lucide-react";
|
||||
import ToolTip from "@/components/ToolTip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import React, { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { RootState } from "@/store/store";
|
||||
import {
|
||||
|
|
@ -21,26 +19,29 @@ import {
|
|||
} from "@dnd-kit/sortable";
|
||||
import { setPresentationData } from "@/store/slices/presentationGeneration";
|
||||
import { SortableSlide } from "./SortableSlide";
|
||||
import { SortableListItem } from "./SortableListItem";
|
||||
import SlideScale from "../../components/PresentationRender";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useRouter } from "next/navigation";
|
||||
import NewSlide from "./NewSlide";
|
||||
|
||||
interface SidePanelProps {
|
||||
selectedSlide: number;
|
||||
onSlideClick: (index: number) => void;
|
||||
isMobilePanelOpen: boolean;
|
||||
setIsMobilePanelOpen: (value: boolean) => void;
|
||||
presentationId: string;
|
||||
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const SidePanel = ({
|
||||
selectedSlide,
|
||||
onSlideClick,
|
||||
isMobilePanelOpen,
|
||||
setIsMobilePanelOpen,
|
||||
presentationId,
|
||||
|
||||
loading,
|
||||
}: SidePanelProps) => {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const [active, setActive] = useState<"list" | "grid">("grid");
|
||||
|
||||
const router = useRouter();
|
||||
const [showNewSlideSelection, setShowNewSlideSelection] = useState(false);
|
||||
|
||||
const { presentationData, isStreaming } = useSelector(
|
||||
(state: RootState) => state.presentationGeneration
|
||||
|
|
@ -48,13 +49,21 @@ const SidePanel = ({
|
|||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const lastSlideIndex = presentationData?.slides?.length
|
||||
? presentationData.slides.length - 1
|
||||
: 0;
|
||||
const lastSlideTemplateId = presentationData?.slides?.[lastSlideIndex]?.layout
|
||||
? presentationData.slides[lastSlideIndex].layout.split(":")[0]
|
||||
: "";
|
||||
|
||||
const handleAddSlideClick = () => {
|
||||
if (!presentationData?.slides?.length || isStreaming) return;
|
||||
setShowNewSlideSelection(true);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (window.innerWidth < 768) {
|
||||
setIsOpen(isMobilePanelOpen);
|
||||
}
|
||||
}, [isMobilePanelOpen]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
|
|
@ -67,12 +76,7 @@ const SidePanel = ({
|
|||
})
|
||||
);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
if (window.innerWidth < 768) {
|
||||
setIsMobilePanelOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleDragEnd = (event: any) => {
|
||||
const { active, over } = event;
|
||||
|
|
@ -119,196 +123,97 @@ const SidePanel = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop Toggle Button - Always visible when panel is closed */}
|
||||
{!isOpen && (
|
||||
<div className="hidden xl:block fixed left-4 top-1/2 -translate-y-1/2 z-50">
|
||||
<ToolTip content="Open Panel">
|
||||
<Button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="bg-white hover:bg-gray-50 shadow-lg"
|
||||
>
|
||||
<PanelRightOpen className="text-black" size={20} />
|
||||
</Button>
|
||||
</ToolTip>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-[#F6F6F9] pt-8 px-4 w-[200px]">
|
||||
|
||||
{/* Mobile Toggle Button */}
|
||||
{!isMobilePanelOpen && (
|
||||
<div className="xl:hidden fixed left-4 bottom-4 z-50">
|
||||
<ToolTip content="Show Panel">
|
||||
<Button
|
||||
onClick={() => setIsMobilePanelOpen(true)}
|
||||
className="bg-[#5146E5] text-white p-3 rounded-full shadow-lg"
|
||||
>
|
||||
<PanelRightOpen className="text-white" size={20} />
|
||||
</Button>
|
||||
</ToolTip>
|
||||
</div>
|
||||
)}
|
||||
<img onClick={() => {
|
||||
router.push("/dashboard");
|
||||
}} src="/logo-with-bg.png" alt="" className="w-10 h-10 cursor-pointer object-contain" />
|
||||
|
||||
<Separator orientation="horizontal" className="my-6 " />
|
||||
<div
|
||||
className={`
|
||||
fixed xl:relative h-full z-50 xl:z-auto
|
||||
fixed xl:relative h-full z-50 xl:z-auto
|
||||
transition-all duration-300 ease-in-out
|
||||
${isOpen ? "ml-0" : "-ml-[300px]"}
|
||||
${isMobilePanelOpen
|
||||
? "translate-x-0"
|
||||
: "-translate-x-full xl:translate-x-0"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div
|
||||
|
||||
className="min-w-[300px] bg-white max-w-[300px] h-[calc(100vh-120px)] rounded-[20px] hide-scrollbar overflow-hidden slide-theme shadow-xl"
|
||||
className="w-full h-[calc(100vh-120px)] hide-scrollbar overflow-hidden slide-theme "
|
||||
>
|
||||
<div
|
||||
className="sticky top-0 z-40 px-6 py-4"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center justify-start gap-4">
|
||||
<ToolTip content="Image Preview">
|
||||
<Button
|
||||
className={`${active === "grid"
|
||||
? "bg-[#5141e5] hover:bg-[#4638c7]"
|
||||
: "bg-white hover:bg-white"
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (!isStreaming) {
|
||||
setActive("grid")
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LayoutList
|
||||
className={`${active === "grid" ? "text-white" : "text-black"
|
||||
}`}
|
||||
size={20}
|
||||
/>
|
||||
</Button>
|
||||
</ToolTip>
|
||||
<ToolTip content="List Preview">
|
||||
<Button
|
||||
className={`${active === "list"
|
||||
? "bg-[#5141e5] hover:bg-[#4638c7]"
|
||||
: "bg-white hover:bg-white"
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (!isStreaming) {
|
||||
setActive("list")
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListTree
|
||||
className={`${active === "list" ? "text-white" : "text-black"
|
||||
}`}
|
||||
size={20}
|
||||
/>
|
||||
</Button>
|
||||
</ToolTip>
|
||||
</div>
|
||||
<X
|
||||
onClick={handleClose}
|
||||
className="text-[#6c7081] cursor-pointer hover:text-gray-600"
|
||||
size={20}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xl font-normal pb-3.5 text-[#000000]">Slides</p>
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{/* List Preview */}
|
||||
{active === "list" && (
|
||||
<div className="p-4 overflow-y-auto hide-scrollbar h-[calc(100%-100px)]">
|
||||
{isStreaming ? (
|
||||
presentationData &&
|
||||
presentationData?.slides.map((slide: any, index: number) => (
|
||||
<div
|
||||
key={`${index}-${slide.type}-${slide.id}`}
|
||||
className={`p-3 cursor-pointer rounded-lg slide-box`}
|
||||
>
|
||||
<span className="font-medium slide-title">
|
||||
Slide {index + 1}
|
||||
</span>
|
||||
<p className="text-sm slide-description">
|
||||
{slide.content.title}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<SortableContext
|
||||
items={
|
||||
presentationData?.slides.map((slide: any) => slide.id!) || []
|
||||
}
|
||||
strategy={verticalListSortingStrategy}
|
||||
<div className=" overflow-y-auto hide-scrollbar h-[calc(100%-140px)] space-y-3.5">
|
||||
{isStreaming ? (
|
||||
presentationData &&
|
||||
presentationData?.slides.map((slide: any, index: number) => (
|
||||
<div
|
||||
key={`${slide.id}-${index}`}
|
||||
onClick={() => onSlideClick(index)}
|
||||
className={` cursor-pointer ring-2 rounded-[12px] transition-all duration-200 ${selectedSlide === index ? ' ring-[#5141e5]' : 'ring-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-2" id={`slide-${selectedSlide}`}>
|
||||
{presentationData &&
|
||||
presentationData?.slides.map((slide: any, index: number) => (
|
||||
<SortableListItem
|
||||
key={`${slide.id}-${index}`}
|
||||
slide={slide}
|
||||
index={index}
|
||||
selectedSlide={selectedSlide}
|
||||
onSlideClick={onSlideClick}
|
||||
/>
|
||||
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grid Preview */}
|
||||
{active === "grid" && (
|
||||
<div className="p-4 overflow-y-auto hide-scrollbar h-[calc(100%-100px)] space-y-4">
|
||||
{isStreaming ? (
|
||||
presentationData &&
|
||||
presentationData?.slides.map((slide: any, index: number) => (
|
||||
<div
|
||||
key={`${slide.id}-${index}`}
|
||||
onClick={() => onSlideClick(index)}
|
||||
className={` cursor-pointer ring-2 p-1 rounded-md transition-all duration-200 ${selectedSlide === index ? ' ring-[#5141e5]' : 'ring-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className=" bg-white pointer-events-none relative overflow-hidden aspect-video">
|
||||
<div className="absolute bg-gray-100/5 z-50 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%]">
|
||||
<SlideScale slide={slide} />
|
||||
</div>
|
||||
<div className=" bg-white pointer-events-none relative overflow-hidden aspect-video">
|
||||
<div className="absolute bg-gray-100/5 z-50 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%]">
|
||||
<SlideScale slide={slide} />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<SortableContext
|
||||
items={
|
||||
presentationData?.slides.map((slide: any) => slide.id || `${slide.index}`) || []
|
||||
}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{presentationData &&
|
||||
presentationData?.slides.map((slide: any, index: number) => (
|
||||
<SortableSlide
|
||||
key={`${slide.id}-${index}`}
|
||||
slide={slide}
|
||||
index={index}
|
||||
selectedSlide={selectedSlide}
|
||||
onSlideClick={onSlideClick}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<SortableContext
|
||||
items={
|
||||
presentationData?.slides.map((slide: any) => slide.id || `${slide.index}`) || []
|
||||
}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{presentationData &&
|
||||
presentationData?.slides.map((slide: any, index: number) => (
|
||||
<SortableSlide
|
||||
key={`${slide.id}-${index}`}
|
||||
slide={slide}
|
||||
index={index}
|
||||
selectedSlide={selectedSlide}
|
||||
onSlideClick={onSlideClick}
|
||||
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
)}
|
||||
</div>
|
||||
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DndContext>
|
||||
<Separator orientation="horizontal" className=" " />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddSlideClick}
|
||||
className="pt-6 gap-2 flex flex-col py-2 duration-300 items-center justify-center rounded-lg cursor-pointer mx-auto"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span className="text-[11px] font-normal text-[#000000]">Add Slide</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
{showNewSlideSelection && lastSlideTemplateId && (
|
||||
<div className="fixed inset-0 z-[60] bg-black/50 overflow-y-auto p-4">
|
||||
<div className="min-h-full flex items-start justify-center py-8">
|
||||
<NewSlide
|
||||
index={lastSlideIndex}
|
||||
templateID={lastSlideTemplateId}
|
||||
setShowNewSlideSelection={setShowNewSlideSelection}
|
||||
presentationId={presentationId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useEffect, useState, useMemo } from "react";
|
||||
import { Loader2, PlusIcon, Trash2, WandSparkles, StickyNote } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Loader2, PlusIcon, Trash2, Pencil, Trash } from "lucide-react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
|
|
@ -18,9 +18,9 @@ import {
|
|||
} from "@/store/slices/presentationGeneration";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
|
||||
import NewSlide from "../../components/NewSlide";
|
||||
import { addToHistory } from "@/store/slices/undoRedoSlice";
|
||||
import { V1ContentRender } from "../../components/V1ContentRender";
|
||||
import NewSlide from "./NewSlide";
|
||||
|
||||
interface SlideContentProps {
|
||||
slide: any;
|
||||
|
|
@ -32,6 +32,9 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => {
|
|||
const dispatch = useDispatch();
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [showNewSlideSelection, setShowNewSlideSelection] = useState(false);
|
||||
const [isEditPopoverOpen, setIsEditPopoverOpen] = useState(false);
|
||||
const [isSpeakerPopoverOpen, setIsSpeakerPopoverOpen] = useState(false);
|
||||
const [editPrompt, setEditPrompt] = useState("");
|
||||
const { presentationData, isStreaming } = useSelector(
|
||||
(state: RootState) => state.presentationGeneration
|
||||
);
|
||||
|
|
@ -41,26 +44,24 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => {
|
|||
const pathname = usePathname();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const element = document.getElementById(
|
||||
`slide-${slide.index}-prompt`
|
||||
) as HTMLInputElement;
|
||||
const value = element?.value;
|
||||
if (!value?.trim()) {
|
||||
if (!editPrompt.trim()) {
|
||||
toast.error("Please enter a prompt before submitting");
|
||||
return;
|
||||
}
|
||||
setIsUpdating(true);
|
||||
|
||||
try {
|
||||
trackEvent(MixpanelEvent.Slide_Update_From_Prompt_Button_Clicked, { pathname });
|
||||
trackEvent(MixpanelEvent.Slide_Edit_API_Call);
|
||||
const response = await PresentationGenerationApi.editSlide(
|
||||
slide.id,
|
||||
value
|
||||
editPrompt
|
||||
);
|
||||
|
||||
if (response) {
|
||||
dispatch(updateSlide({ index: slide.index, slide: response }));
|
||||
toast.success("Slide updated successfully");
|
||||
setEditPrompt("");
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Error in slide editing:", error);
|
||||
|
|
@ -71,8 +72,10 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => {
|
|||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteSlide = async () => {
|
||||
try {
|
||||
trackEvent(MixpanelEvent.Slide_Delete_Slide_Button_Clicked, { pathname });
|
||||
trackEvent(MixpanelEvent.Slide_Delete_API_Call);
|
||||
// Add current state to past
|
||||
dispatch(addToHistory({
|
||||
|
|
@ -132,7 +135,7 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => {
|
|||
<>
|
||||
<div
|
||||
id={`slide-${slide.index}`}
|
||||
className=" w-full max-w-[1280px] main-slide flex items-center max-md:mb-4 justify-center relative"
|
||||
className=" w-full main-slide flex items-center max-md:mb-4 justify-center relative"
|
||||
>
|
||||
{isStreaming && (
|
||||
<Loader2 className="w-8 h-8 absolute right-2 top-2 z-30 text-blue-800 animate-spin" />
|
||||
|
|
@ -140,7 +143,7 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => {
|
|||
<div
|
||||
data-layout={slide.layout}
|
||||
data-group={slide.layout_group}
|
||||
className={` w-full group `}
|
||||
className={` w-full group font-syne `}
|
||||
>
|
||||
<V1ContentRender slide={slide} isEditMode={true} theme={null} />
|
||||
{!showNewSlideSelection && (
|
||||
|
|
@ -170,96 +173,116 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => {
|
|||
)}
|
||||
|
||||
{!isStreaming && (
|
||||
<ToolTip content="Delete slide">
|
||||
<div
|
||||
onClick={() => {
|
||||
trackEvent(MixpanelEvent.Slide_Delete_Slide_Button_Clicked, { pathname });
|
||||
onDeleteSlide();
|
||||
}}
|
||||
className="absolute top-2 z-20 sm:top-4 right-2 sm:right-4 hidden md:block transition-transform"
|
||||
>
|
||||
<Trash2 className="text-gray-500 text-xl cursor-pointer" />
|
||||
</div>
|
||||
</ToolTip>
|
||||
)}
|
||||
{!isStreaming && (
|
||||
<div className="absolute top-2 z-20 sm:top-4 hidden md:block left-2 sm:left-4 transition-transform">
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<ToolTip content="Update slide using prompt">
|
||||
<div
|
||||
className={`p-2 group-hover:scale-105 rounded-lg bg-[#5141e5] hover:shadow-md transition-all duration-300 cursor-pointer shadow-md `}
|
||||
>
|
||||
<WandSparkles className="w-4 sm:w-5 h-4 sm:h-5 text-white" />
|
||||
</div>
|
||||
</ToolTip>
|
||||
<div
|
||||
className={`absolute right-3 top-3 z-30 hidden md:flex flex-row items-center gap-2 rounded-[28px] border border-gray-200/80 bg-white/95 px-2.5 py-2 ${isEditPopoverOpen || isSpeakerPopoverOpen
|
||||
? "opacity-100 pointer-events-auto"
|
||||
: "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto"
|
||||
}`}
|
||||
style={{
|
||||
boxShadow: "0 2px 13.2px 0 rgba(0, 0, 0, 0.10)"
|
||||
}}
|
||||
>
|
||||
<Popover open={isEditPopoverOpen} onOpenChange={setIsEditPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex px-3.5 py-2.5 items-center justify-center rounded-full bg-[#F7F6F9] font-syne"
|
||||
>
|
||||
<ToolTip content="Update slide using prompt">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</ToolTip>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="right"
|
||||
align="start"
|
||||
sideOffset={10}
|
||||
className="w-[280px] sm:w-[400px] z-20"
|
||||
side="bottom"
|
||||
align="center"
|
||||
sideOffset={12}
|
||||
className="z-30 w-[340px] rounded-2xl border border-gray-200 bg-white p-0 shadow-2xl font-syne"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<form
|
||||
className="flex flex-col gap-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
>
|
||||
<Textarea
|
||||
id={`slide-${slide.index}-prompt`}
|
||||
placeholder="Enter your prompt here..."
|
||||
className="w-full min-h-[100px] max-h-[100px] p-2 text-sm border rounded-lg focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
disabled={isUpdating}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
rows={4}
|
||||
wrap="soft"
|
||||
/>
|
||||
<button
|
||||
disabled={isUpdating}
|
||||
type="submit"
|
||||
className={`bg-gradient-to-r from-[#9034EA] to-[#5146E5] rounded-[32px] px-4 py-2 text-white flex items-center justify-end gap-2 ml-auto ${isUpdating ? "opacity-70 cursor-not-allowed" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
trackEvent(MixpanelEvent.Slide_Update_From_Prompt_Button_Clicked, { pathname });
|
||||
}}
|
||||
>
|
||||
{isUpdating ? "Updating..." : "Update"}
|
||||
<SendHorizontal className="w-4 sm:w-5 h-4 sm:h-5" />
|
||||
</button>
|
||||
</form>
|
||||
<div className="border-b border-gray-100 px-4 py-3">
|
||||
<p className="text-sm font-semibold text-gray-900">Update slide</p>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Describe how this slide should be improved.
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
className="flex flex-col gap-3 p-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
>
|
||||
<Textarea
|
||||
id={`slide-${slide.index}-prompt`}
|
||||
value={editPrompt}
|
||||
placeholder="Enter your prompt here..."
|
||||
className="min-h-[110px] max-h-[180px] w-full resize-none rounded-xl border border-gray-200 p-3 text-sm focus-visible:ring-1 focus-visible:ring-[#5141e5]"
|
||||
disabled={isUpdating}
|
||||
onChange={(e) => setEditPrompt(e.target.value)}
|
||||
rows={5}
|
||||
wrap="soft"
|
||||
/>
|
||||
<button
|
||||
disabled={isUpdating}
|
||||
type="submit"
|
||||
className={`ml-auto flex items-center justify-center gap-2 rounded-full bg-gradient-to-r from-[#9034EA] to-[#5146E5] px-4 py-2 text-sm font-medium text-white transition-opacity ${isUpdating ? "cursor-not-allowed opacity-70" : "hover:opacity-90"}`}
|
||||
>
|
||||
{isUpdating ? "Updating..." : "Update"}
|
||||
<SendHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
{/* Speaker Notes */}
|
||||
{!isStreaming && slide?.speaker_note && (
|
||||
<div className="absolute top-2 z-20 sm:top-4 right-8 sm:right-12 hidden md:block transition-transform">
|
||||
<Popover>
|
||||
|
||||
<Popover open={isSpeakerPopoverOpen} onOpenChange={setIsSpeakerPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<div className=" cursor-pointer ">
|
||||
<ToolTip content="Show speaker notes">
|
||||
<StickyNote className="text-xl text-gray-500" />
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
background: "linear-gradient(270deg, #D5CAFC 2.4%, #E3D2EB 27.88%, #F4DCD3 69.23%, #FDE4C2 100%)",
|
||||
|
||||
}}
|
||||
className={`flex px-4 py-2.5 items-center justify-center rounded-full border font-syne ${slide?.speaker_note
|
||||
? "border-violet-200 bg-violet-50 text-violet-700"
|
||||
: "border-gray-200 bg-white text-gray-600"
|
||||
}`}
|
||||
>
|
||||
<ToolTip content="Edit speaker notes">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M5.13334 11.6665V9.27482L6.24167 9.39149C6.56434 9.37356 6.86969 9.23977 7.1016 9.01472C7.33351 8.78966 7.4764 8.48847 7.50401 8.16649V4.84149C7.50787 4.0011 7.17774 3.1936 6.58624 2.59663C5.99473 1.99965 5.1903 1.6621 4.34992 1.65824C3.50954 1.65437 2.70204 1.9845 2.10506 2.57601C1.50809 3.16751 1.17054 3.97194 1.16667 4.81232C1.16667 6.44565 1.54934 6.59382 1.75001 7.46649C1.88562 7.99351 1.89143 8.54556 1.76692 9.07532L1.16667 11.6665" stroke="black" strokeWidth="1.16667" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M11.55 10.3833C12.3701 9.56317 12.8309 8.45095 12.8312 7.29115C12.8316 6.13134 12.3714 5.01886 11.5518 4.19824" stroke="black" strokeWidth="1.16667" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M9.91667 8.74974C10.1075 8.55893 10.2586 8.33217 10.3613 8.08258C10.464 7.83299 10.5161 7.56553 10.5148 7.29566C10.5134 7.02578 10.4586 6.75885 10.3534 6.51031C10.2482 6.26177 10.0948 6.03654 9.90208 5.84766" stroke="black" strokeWidth="1.16667" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</ToolTip>
|
||||
</div>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="left" align="start" sideOffset={10} className="w-[320px] z-30">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-gray-600">Speaker notes</p>
|
||||
<div className="text-sm text-gray-800 whitespace-pre-wrap max-h-64 overflow-auto">
|
||||
{slide.speaker_note}
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="center"
|
||||
sideOffset={12}
|
||||
className="z-30 w-[340px] rounded-2xl border border-gray-200 bg-white p-0 shadow-2xl font-syne"
|
||||
>
|
||||
<div className="border-b border-gray-100 px-4 py-3">
|
||||
<p className="text-sm font-semibold text-gray-900">Speaker notes</p>
|
||||
|
||||
</div>
|
||||
<div className="space-y-3 p-4">
|
||||
<div className="max-h-[220px] min-h-[100px] overflow-auto whitespace-pre-wrap rounded-xl border border-gray-200 bg-gray-50 p-3 text-sm text-gray-800">
|
||||
{slide?.speaker_note?.trim() || "No speaker notes for this slide."}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDeleteSlide}
|
||||
className="flex px-4 py-2.5 items-center justify-center rounded-full border border-gray-200 bg-white text-gray-600 font-syne"
|
||||
>
|
||||
<ToolTip content="Delete slide">
|
||||
<Trash className="h-4 w-4" />
|
||||
</ToolTip>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ interface SortableSlideProps {
|
|||
selectedSlide: number;
|
||||
onSlideClick: (index: any) => void;
|
||||
}
|
||||
const SCALE = 0.2;
|
||||
const SCALE = 0.125;
|
||||
|
||||
export function SortableSlide({ slide, index, selectedSlide, onSlideClick }: SortableSlideProps) {
|
||||
const searchParams = useSearchParams();
|
||||
|
|
@ -55,7 +55,7 @@ export function SortableSlide({ slide, index, selectedSlide, onSlideClick }: Sor
|
|||
{...attributes}
|
||||
{...listeners}
|
||||
onClick={handleClick}
|
||||
className={` cursor-pointer border-[3px] relative p-1 shadow-lg rounded-md transition-all duration-200 ${selectedSlide === index ? ' border-[#5141e5]' : 'border-gray-300'
|
||||
className={` cursor-pointer border relative p-1 rounded-[12px] transition-all duration-200 ${selectedSlide === index ? ' border-[#BDB4FE]' : 'border-[#EDEEEF]'
|
||||
}`}
|
||||
>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,121 @@
|
|||
"use client";
|
||||
import React, { useState } from 'react'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Palette } from 'lucide-react';
|
||||
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { updateTheme } from '@/store/slices/presentationGeneration';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useFontLoader } from '../../hooks/useFontLoad';
|
||||
const ThemeSelector = ({ current_theme, themes: allThemes }: { current_theme: any, themes: any[] }) => {
|
||||
const [currentTheme, setCurrentTheme] = useState<any>(current_theme)
|
||||
const dispatch = useDispatch()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const router = useRouter()
|
||||
const applyTheme = async (theme: any) => {
|
||||
const element = document.getElementById('presentation-slides-wrapper')
|
||||
if (!element) return;
|
||||
if (allThemes.length === 0) return;
|
||||
setCurrentTheme(theme)
|
||||
clearTheme()
|
||||
if (!theme.data.colors['graph_0']) { return; }
|
||||
const cssVariables = {
|
||||
'--primary-color': theme.data.colors['primary'],
|
||||
'--background-color': theme.data.colors['background'],
|
||||
'--card-color': theme.data.colors['card'],
|
||||
'--stroke': theme.data.colors['stroke'],
|
||||
'--primary-text': theme.data.colors['primary_text'],
|
||||
'--background-text': theme.data.colors['background_text'],
|
||||
'--graph-0': theme.data.colors['graph_0'],
|
||||
'--graph-1': theme.data.colors['graph_1'],
|
||||
'--graph-2': theme.data.colors['graph_2'],
|
||||
'--graph-3': theme.data.colors['graph_3'],
|
||||
'--graph-4': theme.data.colors['graph_4'],
|
||||
'--graph-5': theme.data.colors['graph_5'],
|
||||
'--graph-6': theme.data.colors['graph_6'],
|
||||
'--graph-7': theme.data.colors['graph_7'],
|
||||
'--graph-8': theme.data.colors['graph_8'],
|
||||
'--graph-9': theme.data.colors['graph_9'],
|
||||
}
|
||||
Object.entries(cssVariables).forEach(([key, value]) => {
|
||||
element.style.setProperty(key, value)
|
||||
})
|
||||
useFontLoader({ [theme.data.fonts.textFont.name]: theme.data.fonts.textFont.url })
|
||||
|
||||
// Apply fonts to preview container
|
||||
element.style.setProperty('font-family', `"${theme.data.fonts.textFont.name}"`)
|
||||
element.style.setProperty('--heading-font-family', `"${theme.data.fonts.textFont.name}"`)
|
||||
element.style.setProperty('--body-font-family', `"${theme.data.fonts.textFont.name}"`)
|
||||
|
||||
dispatch(updateTheme(theme))
|
||||
}
|
||||
const clearTheme = () => {
|
||||
const element = document.getElementById('presentation-slides-wrapper')
|
||||
if (!element) return;
|
||||
element.style.removeProperty('--primary-color');
|
||||
element.style.removeProperty('--background-color');
|
||||
element.style.removeProperty('--card-color');
|
||||
element.style.removeProperty('--stroke');
|
||||
element.style.removeProperty('--primary-text');
|
||||
element.style.removeProperty('--background-text');
|
||||
element.style.removeProperty('--graph-0');
|
||||
element.style.removeProperty('--graph-1');
|
||||
element.style.removeProperty('--graph-2');
|
||||
element.style.removeProperty('--graph-3');
|
||||
element.style.removeProperty('--graph-4');
|
||||
element.style.removeProperty('--graph-5');
|
||||
element.style.removeProperty('--graph-6');
|
||||
element.style.removeProperty('--graph-7');
|
||||
element.style.removeProperty('--graph-8');
|
||||
element.style.removeProperty('--graph-9');
|
||||
}
|
||||
const resetTheme = async () => {
|
||||
clearTheme();
|
||||
|
||||
dispatch(updateTheme(null))
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger>
|
||||
<button className={`text-sm px-[18px] py-2.5 gap-1.5 flex items-center border border-[#EDEEEF] bg-[#F6F6F9] duration-300 rounded-[88px] font-medium font-syne ${isOpen ? 'text-[#007AFF]' : 'text-black'}`}>
|
||||
<Palette className={`h-4 w-4 ${isOpen ? 'text-[#007AFF]' : 'text-black'}`} /> Theme
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-fit rounded-[18px] max-h-80 overflow-y-auto hide-scrollbar">
|
||||
<div className='pb-2 flex gap-2 justify-end'>
|
||||
<button className='text-xs text-gray-500 pb-2 text-right underline' onClick={() => router.push(`/theme?tab=new-theme`)}>+Customize Theme</button>
|
||||
<button className='text-xs text-gray-500 pb-2 text-right underline' onClick={resetTheme}>Reset Theme</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
|
||||
{allThemes && allThemes.length > 0 && allThemes.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
onClick={() => applyTheme(t)}
|
||||
className={`text-left group relative`}
|
||||
>
|
||||
|
||||
<div className={`rounded-xl cursor-pointer p-1 border shadow-sm bg-white transition-all group-hover:shadow-md ${currentTheme.id === t.id ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300'}`}>
|
||||
<div className="rounded-lg p-2" style={{ backgroundColor: t.data.colors['background'] }}>
|
||||
<div className="rounded-md shadow-sm p-3" style={{ backgroundColor: t.data.colors['card'] }}>
|
||||
<div className="w-16 h-2 rounded-full mb-2" style={{ backgroundColor: t.data.colors['background_text'] }} />
|
||||
<div className="w-12 h-2 rounded-full mb-1" style={{ backgroundColor: t.data.colors['background_text'] }} />
|
||||
<div className="w-8 h-2 rounded-full mb-3" style={{ backgroundColor: t.data.colors['background_text'] }} />
|
||||
<div className="w-8 h-3 rounded-full" style={{ backgroundColor: t.data.colors['primary'] }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-center font-medium text-gray-700 truncate w-full">
|
||||
{t.name}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export default ThemeSelector
|
||||
|
|
@ -3,7 +3,9 @@ import { useDispatch } from "react-redux";
|
|||
import { toast } from "sonner";
|
||||
import { setPresentationData } from "@/store/slices/presentationGeneration";
|
||||
import { DashboardApi } from '../../services/api/dashboard';
|
||||
import { clearHistory } from "@/store/slices/undoRedoSlice";
|
||||
import { clearHistory } from "@/store/slices/undoRedoSlice";
|
||||
import { useFontLoader } from "../../hooks/useFontLoad";
|
||||
import { Theme } from "../../services/api/types";
|
||||
|
||||
|
||||
export const usePresentationData = (
|
||||
|
|
@ -13,6 +15,41 @@ export const usePresentationData = (
|
|||
) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const applyTheme = async (theme: Theme) => {
|
||||
const element = document.getElementById('presentation-slides-wrapper')
|
||||
if (!element) return;
|
||||
if (!theme || !theme.data) { return; }
|
||||
if (!theme.data.colors['graph_0']) { return; }
|
||||
const cssVariables = {
|
||||
'--primary-color': theme.data.colors['primary'],
|
||||
'--background-color': theme.data.colors['background'],
|
||||
'--card-color': theme.data.colors['card'],
|
||||
'--stroke': theme.data.colors['stroke'],
|
||||
'--primary-text': theme.data.colors['primary_text'],
|
||||
'--background-text': theme.data.colors['background_text'],
|
||||
'--graph-0': theme.data.colors['graph_0'],
|
||||
'--graph-1': theme.data.colors['graph_1'],
|
||||
'--graph-2': theme.data.colors['graph_2'],
|
||||
'--graph-3': theme.data.colors['graph_3'],
|
||||
'--graph-4': theme.data.colors['graph_4'],
|
||||
'--graph-5': theme.data.colors['graph_5'],
|
||||
'--graph-6': theme.data.colors['graph_6'],
|
||||
'--graph-7': theme.data.colors['graph_7'],
|
||||
'--graph-8': theme.data.colors['graph_8'],
|
||||
'--graph-9': theme.data.colors['graph_9'],
|
||||
}
|
||||
Object.entries(cssVariables).forEach(([key, value]) => {
|
||||
element.style.setProperty(key, value)
|
||||
})
|
||||
useFontLoader({ [theme.data.fonts.textFont.name]: theme.data.fonts.textFont.url })
|
||||
|
||||
// Apply fonts to preview container
|
||||
element.style.setProperty('font-family', `"${theme.data.fonts.textFont.name}"`)
|
||||
element.style.setProperty('--heading-font-family', `"${theme.data.fonts.textFont.name}"`)
|
||||
element.style.setProperty('--body-font-family', `"${theme.data.fonts.textFont.name}"`)
|
||||
// Update the Presentation content with theme
|
||||
}
|
||||
|
||||
const fetchUserSlides = useCallback(async () => {
|
||||
try {
|
||||
const data = await DashboardApi.getPresentation(presentationId);
|
||||
|
|
@ -21,6 +58,9 @@ export const usePresentationData = (
|
|||
dispatch(clearHistory());
|
||||
setLoading(false);
|
||||
}
|
||||
if (data?.theme) {
|
||||
applyTheme(data.theme);
|
||||
}
|
||||
} catch (error) {
|
||||
setError(true);
|
||||
toast.error("Failed to load presentation");
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
import { jsonrepair } from "jsonrepair";
|
||||
import { toast } from "sonner";
|
||||
import { MixpanelEvent, trackEvent } from "@/utils/mixpanel";
|
||||
import { getApiUrl } from "@/utils/api";
|
||||
import {getFastAPIUrl} from "@/utils/api";
|
||||
|
||||
export const usePresentationStreaming = (
|
||||
presentationId: string,
|
||||
|
|
@ -31,7 +31,7 @@ export const usePresentationStreaming = (
|
|||
trackEvent(MixpanelEvent.Presentation_Stream_API_Call);
|
||||
|
||||
eventSource = new EventSource(
|
||||
getApiUrl(`api/v1/ppt/presentation/stream/${presentationId}`)
|
||||
`${getFastAPIUrl()}/api/v1/ppt/presentation/stream/${presentationId}`
|
||||
);
|
||||
|
||||
eventSource.addEventListener("response", (event) => {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ const page = () => {
|
|||
const queryId = params.get("id");
|
||||
if (!queryId) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-screen">
|
||||
<div className="flex flex-col items-center justify-center h-screen font-syne">
|
||||
<h1 className="text-2xl font-bold">No presentation id found</h1>
|
||||
<p className="text-gray-500 pb-4">Please try again</p>
|
||||
<Button onClick={() => router.push("/dashboard")}>Go to home</Button>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,49 @@
|
|||
// API Error Response Interface
|
||||
interface ApiErrorResponse {
|
||||
detail?: string;
|
||||
detail?: unknown;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// API Response Handler Utility
|
||||
export class ApiResponseHandler {
|
||||
private static normalizeErrorDetail(detail: unknown): string | null {
|
||||
if (!detail) return null;
|
||||
|
||||
if (typeof detail === "string") {
|
||||
return detail;
|
||||
}
|
||||
|
||||
if (Array.isArray(detail)) {
|
||||
const parts = detail
|
||||
.map((item) => {
|
||||
if (typeof item === "string") return item;
|
||||
if (item && typeof item === "object") {
|
||||
const maybeMsg = (item as { msg?: unknown }).msg;
|
||||
const maybeLoc = (item as { loc?: unknown }).loc;
|
||||
const locPath = Array.isArray(maybeLoc)
|
||||
? maybeLoc
|
||||
.filter((v) => typeof v === "string" || typeof v === "number")
|
||||
.join(".")
|
||||
: "";
|
||||
if (typeof maybeMsg === "string") {
|
||||
return locPath ? `${locPath}: ${maybeMsg}` : maybeMsg;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((v): v is string => Boolean(v));
|
||||
|
||||
return parts.length ? parts.join("; ") : JSON.stringify(detail);
|
||||
}
|
||||
|
||||
if (typeof detail === "object") {
|
||||
return JSON.stringify(detail);
|
||||
}
|
||||
|
||||
return String(detail);
|
||||
}
|
||||
|
||||
|
||||
static async handleResponse(response: Response, defaultErrorMessage: string): Promise<any> {
|
||||
// Handle successful responses
|
||||
|
|
@ -32,8 +69,9 @@ export class ApiResponseHandler {
|
|||
const errorData: ApiErrorResponse = await response.json();
|
||||
|
||||
// Extract error message in order of preference
|
||||
if (errorData.detail) {
|
||||
errorMessage = errorData.detail;
|
||||
const normalizedDetail = this.normalizeErrorDetail(errorData.detail);
|
||||
if (normalizedDetail) {
|
||||
errorMessage = normalizedDetail;
|
||||
} else if (errorData.message) {
|
||||
errorMessage = errorData.message;
|
||||
} else if (errorData.error) {
|
||||
|
|
@ -63,8 +101,9 @@ export class ApiResponseHandler {
|
|||
const errorData: ApiErrorResponse = await response.json();
|
||||
|
||||
// Extract error message in order of preference
|
||||
if (errorData.detail) {
|
||||
errorMessage = errorData.detail;
|
||||
const normalizedDetail = this.normalizeErrorDetail(errorData.detail);
|
||||
if (normalizedDetail) {
|
||||
errorMessage = normalizedDetail;
|
||||
} else if (errorData.message) {
|
||||
errorMessage = errorData.message;
|
||||
} else if (errorData.error) {
|
||||
|
|
|
|||
|
|
@ -14,13 +14,13 @@ export interface PresentationResponse {
|
|||
n_slides: number;
|
||||
prompt: string;
|
||||
summary: string | null;
|
||||
theme: string;
|
||||
titles: string[];
|
||||
user_id: string;
|
||||
vector_store: any;
|
||||
theme: Record<string, any> | null;
|
||||
titles: string[];
|
||||
user_id: string;
|
||||
vector_store: any;
|
||||
|
||||
thumbnail: string;
|
||||
slides: any[];
|
||||
thumbnail: string;
|
||||
slides: any[];
|
||||
}
|
||||
|
||||
export class DashboardApi {
|
||||
|
|
@ -28,45 +28,45 @@ export class DashboardApi {
|
|||
static async getPresentations(): Promise<PresentationResponse[]> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
getApiUrl("api/v1/ppt/presentation/all"),
|
||||
getApiUrl(`/api/v1/ppt/presentation/all`),
|
||||
{
|
||||
method: "GET",
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
// Handle the special case where 404 means "no presentations found"
|
||||
if (response.status === 404) {
|
||||
console.log("No presentations found");
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
return await ApiResponseHandler.handleResponse(response, "Failed to fetch presentations");
|
||||
} catch (error) {
|
||||
console.error("Error fetching presentations:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static async getPresentation(id: string) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
getApiUrl(`api/v1/ppt/presentation/${id}`),
|
||||
getApiUrl(`/api/v1/ppt/presentation/${id}`),
|
||||
{
|
||||
method: "GET",
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
return await ApiResponseHandler.handleResponse(response, "Presentation not found");
|
||||
} catch (error) {
|
||||
console.error("Error fetching presentation:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static async deletePresentation(presentation_id: string) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
getApiUrl(`api/v1/ppt/presentation/${presentation_id}`),
|
||||
getApiUrl(`/api/v1/ppt/presentation/${presentation_id}`),
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: getHeader(),
|
||||
|
|
|
|||
|
|
@ -19,13 +19,12 @@ export interface IconSearch {
|
|||
}
|
||||
|
||||
export interface PreviousGeneratedImagesResponse {
|
||||
|
||||
extras: {
|
||||
prompt: string;
|
||||
theme_prompt: string | null;
|
||||
},
|
||||
created_at: string;
|
||||
id: string;
|
||||
path: string;
|
||||
file_url: string;
|
||||
extras: {
|
||||
prompt: string;
|
||||
theme_prompt: string | null;
|
||||
};
|
||||
created_at: string;
|
||||
id: string;
|
||||
path: string;
|
||||
file_url?: string;
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ export class PresentationGenerationApi {
|
|||
|
||||
try {
|
||||
const response = await fetch(
|
||||
getApiUrl("api/v1/ppt/files/upload"),
|
||||
getApiUrl(`/api/v1/ppt/files/upload`),
|
||||
{
|
||||
method: "POST",
|
||||
headers: getHeaderForFormData(),
|
||||
|
|
@ -32,7 +32,7 @@ export class PresentationGenerationApi {
|
|||
static async decomposeDocuments(documentKeys: string[]) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
getApiUrl("api/v1/ppt/files/decompose"),
|
||||
getApiUrl(`/api/v1/ppt/files/decompose`),
|
||||
{
|
||||
method: "POST",
|
||||
headers: getHeader(),
|
||||
|
|
@ -76,7 +76,7 @@ export class PresentationGenerationApi {
|
|||
}) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
getApiUrl("api/v1/ppt/presentation/create"),
|
||||
getApiUrl(`/api/v1/ppt/presentation/create`),
|
||||
{
|
||||
method: "POST",
|
||||
headers: getHeader(),
|
||||
|
|
@ -109,7 +109,7 @@ export class PresentationGenerationApi {
|
|||
) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
getApiUrl("api/v1/ppt/slide/edit"),
|
||||
getApiUrl(`/api/v1/ppt/slide/edit`),
|
||||
{
|
||||
method: "POST",
|
||||
headers: getHeader(),
|
||||
|
|
@ -130,9 +130,8 @@ export class PresentationGenerationApi {
|
|||
|
||||
static async updatePresentationContent(body: any) {
|
||||
try {
|
||||
console.log("Updating presentation with data:", body);
|
||||
const response = await fetch(
|
||||
getApiUrl("api/v1/ppt/presentation/update"),
|
||||
getApiUrl(`/api/v1/ppt/presentation/update`),
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: getHeader(),
|
||||
|
|
@ -151,7 +150,7 @@ export class PresentationGenerationApi {
|
|||
static async presentationPrepare(presentationData: any) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
getApiUrl("api/v1/ppt/presentation/prepare"),
|
||||
getApiUrl(`/api/v1/ppt/presentation/prepare`),
|
||||
{
|
||||
method: "POST",
|
||||
headers: getHeader(),
|
||||
|
|
@ -173,7 +172,7 @@ export class PresentationGenerationApi {
|
|||
static async generateImage(imageGenerate: ImageGenerate) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
getApiUrl(`api/v1/ppt/images/generate?prompt=${imageGenerate.prompt}`),
|
||||
getApiUrl(`/api/v1/ppt/images/generate?prompt=${imageGenerate.prompt}`),
|
||||
{
|
||||
method: "GET",
|
||||
headers: getHeader(),
|
||||
|
|
@ -191,7 +190,7 @@ export class PresentationGenerationApi {
|
|||
static getPreviousGeneratedImages = async (): Promise<PreviousGeneratedImagesResponse[]> => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
getApiUrl("api/v1/ppt/images/generated"),
|
||||
getApiUrl(`/api/v1/ppt/images/generated`),
|
||||
{
|
||||
method: "GET",
|
||||
headers: getHeader(),
|
||||
|
|
@ -208,7 +207,7 @@ export class PresentationGenerationApi {
|
|||
static async searchIcons(iconSearch: IconSearch) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
getApiUrl(`api/v1/ppt/icons/search?query=${iconSearch.query}&limit=${iconSearch.limit}`),
|
||||
getApiUrl(`/api/v1/ppt/icons/search?query=${iconSearch.query}&limit=${iconSearch.limit}`),
|
||||
{
|
||||
method: "GET",
|
||||
headers: getHeader(),
|
||||
|
|
@ -229,7 +228,7 @@ export class PresentationGenerationApi {
|
|||
static async exportAsPPTX(presentationData: any) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
getApiUrl("api/v1/ppt/presentation/export/pptx"),
|
||||
getApiUrl(`/api/v1/ppt/presentation/export/pptx`),
|
||||
{
|
||||
method: "POST",
|
||||
headers: getHeader(),
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { ApiResponseHandler } from "./api-error-handler";
|
||||
import { getApiUrl } from "@/utils/api";
|
||||
|
||||
class TemplateService {
|
||||
|
||||
static async getCustomTemplateSummaries() {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/ppt/template-management/summary`,);
|
||||
const response = await fetch(getApiUrl(`/api/v1/ppt/template-management/summary`),);
|
||||
return await ApiResponseHandler.handleResponse(response, "Failed to get custom template summaries");
|
||||
} catch (error) {
|
||||
console.error("Failed to get custom template summaries", error);
|
||||
|
|
@ -14,7 +15,7 @@ class TemplateService {
|
|||
|
||||
static async getCustomTemplateDetails(templateId: string) {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/ppt/template-management/get-templates/${templateId}`,);
|
||||
const response = await fetch(getApiUrl(`/api/v1/ppt/template-management/get-templates/${templateId}`),);
|
||||
return await ApiResponseHandler.handleResponse(response, "Failed to get custom template details");
|
||||
} catch (error) {
|
||||
console.error("Failed to get custom template details", error);
|
||||
|
|
@ -24,7 +25,7 @@ class TemplateService {
|
|||
|
||||
static async deleteCustomTemplate(presentationId: string) {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/ppt/template-management/delete-templates/${presentationId}`, { method: "DELETE" });
|
||||
const response = await fetch(getApiUrl(`/api/v1/ppt/template-management/delete-templates/${presentationId}`), { method: "DELETE" });
|
||||
return await ApiResponseHandler.handleResponseWithResult(response, "Failed to delete custom template");
|
||||
} catch (error) {
|
||||
console.error("Failed to delete custom template", error);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,121 @@
|
|||
import { ApiResponseHandler } from "./api-error-handler"
|
||||
import { getHeader, getHeaderForFormData } from "./header"
|
||||
import { Theme, ThemeParams } from "./types"
|
||||
import { getApiUrl } from "@/utils/api"
|
||||
|
||||
|
||||
|
||||
class ThemeApi {
|
||||
|
||||
static async getThemes(): Promise<Theme[]> {
|
||||
try {
|
||||
const response = await fetch(getApiUrl(`/api/v1/ppt/themes/all`), {
|
||||
method: "GET",
|
||||
headers: getHeader(),
|
||||
cache: "no-store",
|
||||
})
|
||||
return await ApiResponseHandler.handleResponse(response, "Failed to get themes")
|
||||
} catch (error) {
|
||||
console.error("Error getting themes:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
static async createTheme(theme: ThemeParams) {
|
||||
try {
|
||||
|
||||
const response = await fetch(getApiUrl(`/api/v1/ppt/themes/create`), {
|
||||
method: "POST",
|
||||
headers: getHeader(),
|
||||
body: JSON.stringify(theme),
|
||||
cache: "no-store",
|
||||
})
|
||||
return await ApiResponseHandler.handleResponse(response, "Failed to create theme")
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Error creating theme:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
static async updateTheme(theme: ThemeParams) {
|
||||
try {
|
||||
const response = await fetch(getApiUrl(`/api/v1/ppt/themes/update/${theme.id}`), {
|
||||
method: "PATCH",
|
||||
headers: getHeader(),
|
||||
body: JSON.stringify(theme),
|
||||
cache: "no-store",
|
||||
})
|
||||
return await ApiResponseHandler.handleResponse(response, "Failed to update theme")
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Error updating theme:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
static async deleteTheme(themeId: string) {
|
||||
try {
|
||||
const response = await fetch(getApiUrl(`/api/v1/ppt/themes/delete/${themeId}`), {
|
||||
method: "DELETE",
|
||||
headers: getHeader(),
|
||||
cache: "no-store",
|
||||
})
|
||||
return await ApiResponseHandler.handleResponse(response, "Failed to delete theme")
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Error deleting theme:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
static async generateTheme({ primary, background }: { primary?: string, background?: string }) {
|
||||
try {
|
||||
let body = {}
|
||||
if (primary || background) {
|
||||
body = {
|
||||
primary: primary ?? undefined,
|
||||
background: background ?? undefined,
|
||||
}
|
||||
}
|
||||
const response = await fetch(getApiUrl(`/api/v1/ppt/theme/generate`), {
|
||||
method: "POST",
|
||||
headers: getHeader(),
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
return await ApiResponseHandler.handleResponse(response, "Failed to generate theme")
|
||||
}
|
||||
|
||||
catch (error) {
|
||||
console.error("Error generating theme:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
static async uploadFont(font: File) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", font);
|
||||
const response = await fetch(getApiUrl(`/api/v1/ppt/fonts/upload`), {
|
||||
method: "POST",
|
||||
headers: getHeaderForFormData(),
|
||||
body: formData,
|
||||
})
|
||||
return await ApiResponseHandler.handleResponse(response, "Failed to upload font")
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Error uploading font:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
static async getUserFonts() {
|
||||
try {
|
||||
const response = await fetch(getApiUrl(`/api/v1/ppt/fonts/uploaded`), {
|
||||
method: "GET",
|
||||
headers: getHeader(),
|
||||
})
|
||||
return await ApiResponseHandler.handleResponse(response, "Failed to get user fonts")
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Error getting user fonts:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ThemeApi
|
||||
|
|
@ -25,8 +25,46 @@ export interface DeplotResponse {
|
|||
}
|
||||
|
||||
export interface ImageAssetResponse {
|
||||
message:string;
|
||||
path:string;
|
||||
id:string;
|
||||
file_url:string;
|
||||
message: string;
|
||||
path: string;
|
||||
id: string;
|
||||
file_url?: string;
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface DefaultTheme {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
data: any;
|
||||
}
|
||||
|
||||
|
||||
export interface Theme {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
user: string;
|
||||
logo: string; // image id
|
||||
logo_url?: string; // preview url
|
||||
company_name?: string;
|
||||
data: any;
|
||||
}
|
||||
export interface ThemeParams {
|
||||
id?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
logo: string | null; // image id
|
||||
logo_url?: string | null; // preview url
|
||||
data: any;
|
||||
company_name?: string | null;
|
||||
}
|
||||
|
||||
|
||||
export interface DefaultTheme {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
data: any;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue