Merge branch 'main' into fix/llm-client-issues

This commit is contained in:
sauravniraula 2025-08-14 12:09:30 +05:45
commit dc6c4d1fd3
No known key found for this signature in database
GPG key ID: 60FCC1B5A5E83326
45 changed files with 735 additions and 574 deletions

View file

@ -17,6 +17,8 @@
**Presenton** is an open-source application for generating presentations with AI — all running locally on your device. Stay in control of your data and privacy while using models like OpenAI and Gemini, or use your own hosted models through Ollama.
__✨ Now, generate presentations with your existing PPTX file! Just upload your presentation file to create template design and then use that template to generate on brand and on design presentation on any topic.__
![Demo](readme_assets/demo.gif)
@ -35,7 +37,8 @@
Presenton gives you complete control over your AI presentation workflow. Choose your models, customize your experience, and keep your data private.
* ✅ **Custom Layouts & Themes** — Create unlimited presentation designs with HTML and Tailwind CSS
* ✅ **Custom Templates & Themes** — Create unlimited presentation designs with HTML and Tailwind CSS
* ✅ **AI Template Generation** — Create presentation templates from existing Powerpoint documents.
* ✅ **Flexible Generation** — Build presentations from prompts or uploaded documents
* ✅ **Export Ready** — Save as PowerPoint (PPTX) and PDF with professional formatting
* ✅ **Bring Your Own Key** — Use your own API keys for OpenAI, Google Gemini, Anthropic Claude, or any compatible provider. Only pay for what you use, no hidden fees or subscriptions.
@ -45,7 +48,6 @@ Presenton gives you complete control over your AI presentation workflow. Choose
* ✅ **Versatile Image Generation** — Choose from DALL-E 3, Gemini Flash, Pexels, or Pixabay
* ✅ **Rich Media Support** — Icons, charts, and custom graphics for professional presentations
* ✅ **Runs Locally** — All processing happens on your device, no cloud dependencies
* ✅ **Privacy-First** — Zero tracking, no data stored by us, complete data sovereignty
* ✅ **API Deployment** — Host as your own API service for your team
* ✅ **Fully Open-Source** — Apache 2.0 licensed, inspect, modify, and contribute
* ✅ **Docker Ready** — One-command deployment with GPU support for local models
@ -102,6 +104,10 @@ You can also set the following environment variables to customize the image gene
- **GOOGLE_API_KEY=[Your Google API Key]**: Required if using **gemini_flash** as the image provider.
- **OPENAI_API_KEY=[Your OpenAI API Key]**: Required if using **dall-e-3** as the image provider.
You can disable anonymous telemetry using the following environment variable:
- **DISABLE_ANONYMOUS_TELEMETRY=[true/false]**: Set this to **true** to disable anonymous telemetry.
> **Note:** You can freely choose both the LLM (text generation) and the image provider. Supported image providers: **pexels**, **pixabay**, **gemini_flash** (Google), and **dall-e-3** (OpenAI).
### Using OpenAI
@ -160,7 +166,7 @@ Content-Type: `multipart/form-data`
| prompt | string | Yes | The main topic or prompt for generating the presentation |
| n_slides | integer | No | Number of slides to generate (default: 8, min: 5, max: 15) |
| language | string | No | Language for the presentation (default: "English") |
| template | string | No | Presentation theme (default: "general"). Available options: "classic", "general", "modern", "professional" + Custom templates |
| template | string | No | Presentation template (default: "general"). Available options: "classic", "general", "modern", "professional" + Custom templates |
| documents | File[] | No | Optional list of document files to include in the presentation. Supported file types: PDF, TXT, PPTX, DOCX |
| export_as | string | No | Export format ("pptx" or "pdf", default: "pptx") |

View file

@ -29,6 +29,7 @@ services:
- DISABLE_THINKING=${DISABLE_THINKING}
- WEB_GROUNDING=${WEB_GROUNDING}
- DATABASE_URL=${DATABASE_URL}
- DISABLE_ANONYMOUS_TRACKING=${DISABLE_ANONYMOUS_TRACKING}
production-gpu:
# image: ghcr.io/presenton/presenton:latest
@ -67,6 +68,7 @@ services:
- DISABLE_THINKING=${DISABLE_THINKING}
- WEB_GROUNDING=${WEB_GROUNDING}
- DATABASE_URL=${DATABASE_URL}
- DISABLE_ANONYMOUS_TRACKING=${DISABLE_ANONYMOUS_TRACKING}
development:
build:
@ -97,6 +99,7 @@ services:
- DISABLE_THINKING=${DISABLE_THINKING}
- WEB_GROUNDING=${WEB_GROUNDING}
- DATABASE_URL=${DATABASE_URL}
- DISABLE_ANONYMOUS_TRACKING=${DISABLE_ANONYMOUS_TRACKING}
development-gpu:
build:
@ -134,3 +137,4 @@ services:
- DISABLE_THINKING=${DISABLE_THINKING}
- WEB_GROUNDING=${WEB_GROUNDING}
- DATABASE_URL=${DATABASE_URL}
- DISABLE_ANONYMOUS_TRACKING=${DISABLE_ANONYMOUS_TRACKING}

View file

@ -249,7 +249,7 @@ async def analyze_fonts_in_all_slides(slide_xmls: List[str]) -> FontAnalysisResu
return FontAnalysisResult(
internally_supported_fonts=internally_supported_fonts,
not_supported_fonts=not_supported_fonts
not_supported_fonts=[]
)

View file

@ -221,6 +221,7 @@ async def stream_presentation(
layout_group=layout.name,
layout=slide_layout.id,
index=i,
speaker_note=slide_content.get("__speaker_note__", ""),
content=slide_content,
)
slides.append(slide)

View file

@ -14,7 +14,6 @@ from utils.llm_calls.edit_slide_html import get_edited_slide_html
from utils.llm_calls.select_slide_type_on_edit import get_slide_layout_from_prompt
from utils.process_slides import process_old_and_new_slides_and_fetch_assets
from utils.randomizers import get_random_uuid
from utils.schema_utils import remove_fields_from_schema
SLIDE_ROUTER = APIRouter(prefix="/slide", tags=["Slide"])
@ -59,6 +58,7 @@ async def edit_slide(
sql_session.add(slide)
slide.content = edited_slide_content
slide.layout = slide_layout.id
slide.speaker_note = edited_slide_content.get("__speaker_note__", "")
sql_session.add_all(new_assets)
await sql_session.commit()

View file

@ -57,6 +57,8 @@ class PptxFontModel(BaseModel):
italic: bool = False
color: str = "000000"
font_weight: Optional[int] = 400
underline: Optional[bool] = None
strike: Optional[bool] = None
class PptxFillModel(BaseModel):
@ -154,6 +156,7 @@ class PptxConnectorModel(PptxShapeModel):
class PptxSlideModel(BaseModel):
background: Optional[PptxFillModel] = None
note: Optional[str] = None
shapes: List[
PptxTextBoxModel
| PptxAutoShapeBoxModel

View file

@ -12,6 +12,7 @@ class SlideModel(SQLModel, table=True):
index: int
content: dict = Field(sa_column=Column(JSON))
html_content: Optional[str]
speaker_note: str
properties: Optional[dict] = Field(sa_column=Column(JSON))
def get_new_slide(self, presentation_id: str, content: Optional[dict] = None):
@ -21,6 +22,7 @@ class SlideModel(SQLModel, table=True):
layout_group=self.layout_group,
layout=self.layout,
index=self.index,
speaker_note=self.speaker_note,
content=content or self.content,
properties=self.properties,
)

View file

@ -0,0 +1,65 @@
from html.parser import HTMLParser
from typing import List, Optional
from models.pptx_models import PptxFontModel, PptxTextRunModel
class InlineHTMLToRunsParser(HTMLParser):
def __init__(self, base_font: PptxFontModel):
super().__init__(convert_charrefs=True)
self.base_font = base_font
self.tag_stack: List[str] = []
self.text_runs: List[PptxTextRunModel] = []
def _current_font(self) -> PptxFontModel:
font_json = self.base_font.model_dump()
is_bold = any(tag in ("strong", "b") for tag in self.tag_stack)
is_italic = any(tag in ("em", "i") for tag in self.tag_stack)
is_underline = any(tag == "u" for tag in self.tag_stack)
is_strike = any(tag in ("s", "strike", "del") for tag in self.tag_stack)
is_code = any(tag == "code" for tag in self.tag_stack)
if is_bold:
font_json["font_weight"] = 700
if is_italic:
font_json["italic"] = True
if is_underline:
font_json["underline"] = True
if is_strike:
font_json["strike"] = True
if is_code:
font_json["name"] = "Courier New"
return PptxFontModel(**font_json)
def handle_starttag(self, tag, attrs):
tag = tag.lower()
if tag == "br":
self.text_runs.append(PptxTextRunModel(text="\n"))
return
self.tag_stack.append(tag)
def handle_endtag(self, tag):
tag = tag.lower()
for i in range(len(self.tag_stack) - 1, -1, -1):
if self.tag_stack[i] == tag:
del self.tag_stack[i]
break
def handle_data(self, data):
if data == "":
return
self.text_runs.append(PptxTextRunModel(text=data, font=self._current_font()))
def parse_html_text_to_text_runs(
text: str, base_font: Optional[PptxFontModel] = None
) -> List[PptxTextRunModel]:
normalized_text = text.replace("\r\n", "\n").replace("\r", "\n")
normalized_text = normalized_text.replace("\n", "<br>")
parser = InlineHTMLToRunsParser(base_font if base_font else PptxFontModel())
parser.feed(normalized_text)
return parser.text_runs

View file

@ -1,6 +1,9 @@
import os
from typing import List, Optional
from lxml import etree
from services.html_to_text_runs_service import (
parse_html_text_to_text_runs as parse_inline_html_to_runs,
)
from pptx import Presentation
from pptx.shapes.autoshape import Shape
@ -144,6 +147,9 @@ class PptxPresentationCreator:
if slide_model.background:
self.apply_fill_to_shape(slide.background, slide_model.background)
if slide_model.note:
slide.notes_slide.notes_text_frame.text = slide_model.note
for shape_model in slide_model.shapes:
model_type = type(shape_model)
@ -276,7 +282,7 @@ class PptxPresentationCreator:
text_runs = []
if paragraph_model.text:
text_runs = self.parse_markdown_text_to_text_runs(
text_runs = self.parse_html_text_to_text_runs(
paragraph_model.font, paragraph_model.text
)
elif paragraph_model.text_runs:
@ -286,78 +292,8 @@ class PptxPresentationCreator:
text_run = paragraph.add_run()
self.populate_text_run(text_run, text_run_model)
def parse_markdown_text_to_text_runs(self, font: PptxFontModel, text: str):
text_runs = []
for line in text.split("\n"):
current_pos = 0
while current_pos < len(line):
# Check for bold and italic (***text***)
if (
line[current_pos:].startswith("***")
and "***" in line[current_pos + 3 :]
):
end_pos = line.find("***", current_pos + 3)
text_content = line[current_pos + 3 : end_pos]
font_json = font.model_dump()
font_json["bold"] = True
font_json["italic"] = True
font_json["font_weight"] = 700 # Set font weight to bold
text_runs.append(
PptxTextRunModel(
text=text_content, font=PptxFontModel(**font_json)
)
)
current_pos = end_pos + 3
# Check for bold (**text**)
elif (
line[current_pos:].startswith("**")
and "**" in line[current_pos + 2 :]
):
end_pos = line.find("**", current_pos + 2)
text_content = line[current_pos + 2 : end_pos]
font_json = font.model_dump()
font_json["bold"] = True
font_json["font_weight"] = 700 # Set font weight to bold
text_runs.append(
PptxTextRunModel(
text=text_content, font=PptxFontModel(**font_json)
)
)
current_pos = end_pos + 2
# Check for italic (*text*)
elif (
line[current_pos:].startswith("__")
and "__" in line[current_pos + 2 :]
):
end_pos = line.find("__", current_pos + 2)
text_content = line[current_pos + 2 : end_pos]
font_json = font.model_dump()
font_json["italic"] = True
text_runs.append(
PptxTextRunModel(
text=text_content, font=PptxFontModel(**font_json)
)
)
current_pos = end_pos + 2
else:
# Find the next formatting marker or end of line
next_marker = float("inf")
for marker in ["***", "**", "__"]:
pos = line.find(marker, current_pos)
if pos != -1:
next_marker = min(next_marker, pos)
end_pos = next_marker if next_marker != float("inf") else len(line)
text_content = line[current_pos:end_pos]
if text_content: # Only add non-empty text
text_runs.append(PptxTextRunModel(text=text_content, font=font))
current_pos = end_pos
# Add newline if not the last line
if line != text.split("\n")[-1]:
text_runs.append(PptxTextRunModel(text="\n"))
return text_runs
def parse_html_text_to_text_runs(self, font: Optional[PptxFontModel], text: str):
return parse_inline_html_to_runs(text, font)
def populate_text_run(self, text_run: _Run, text_run_model: PptxTextRunModel):
text_run.text = text_run_model.text
@ -527,6 +463,20 @@ class PptxPresentationCreator:
font.italic = font_model.italic
font.size = Pt(font_model.size)
font.bold = font_model.font_weight >= 600
if font_model.underline is not None:
font.underline = bool(font_model.underline)
if font_model.strike is not None:
self.apply_strike_to_font(font, font_model.strike)
def apply_strike_to_font(self, font: Font, strike: Optional[bool]):
try:
rPr = font._element
if strike is True:
rPr.set("strike", "sngStrike")
elif strike is False:
rPr.set("strike", "noStrike")
except Exception as e:
print(f"Could not apply strikethrough: {e}")
def save(self, path: str):
self._ppt.save(path)

View file

@ -3,10 +3,11 @@ from models.presentation_layout import SlideLayoutModel
from models.sql.slide import SlideModel
from services.llm_client import LLMClient
from utils.llm_provider import get_model
from utils.schema_utils import remove_fields_from_schema
from utils.schema_utils import add_field_in_schema, remove_fields_from_schema
system_prompt = """
Edit Slide data based on provided prompt, follow mentioned steps and notes and provide structured output.
Edit Slide data and speaker note based on provided prompt, follow mentioned steps and notes and provide structured output.
# Notes
- Provide output in language mentioned in **Input**.
@ -14,6 +15,8 @@ system_prompt = """
- Do not change **Image prompts** and **Icon queries** if not asked for in prompt.
- Generate **Image prompts** and **Icon queries** if asked to generate or change in prompt.
- Make sure to follow language guidelines.
- Speaker note should be normal text, not markdown.
- Speaker note should be simple, clear, concise and to the point.
**Go through all notes and steps and make sure they are followed, including mentioned constraints**
"""
@ -61,6 +64,18 @@ async def get_edited_slide_content(
response_schema = remove_fields_from_schema(
slide_layout.json_schema, ["__image_url__", "__icon_url__"]
)
response_schema = add_field_in_schema(
response_schema,
{
"__speaker_note__": {
"type": "string",
"minLength": 100,
"maxLength": 250,
"description": "Speaker note for the slide",
}
},
True,
)
client = LLMClient()
response = await client.generate_structured(

View file

@ -3,7 +3,7 @@ from models.presentation_layout import SlideLayoutModel
from models.presentation_outline_model import SlideOutlineModel
from services.llm_client import LLMClient
from utils.llm_provider import get_model
from utils.schema_utils import remove_fields_from_schema
from utils.schema_utils import add_field_in_schema, remove_fields_from_schema
system_prompt = """
Generate structured slide based on provided outline, follow mentioned steps and notes and provide structured output.
@ -11,6 +11,7 @@ system_prompt = """
# Steps
1. Analyze the outline.
2. Generate structured slide based on the outline.
3. Generate speaker note that is simple, clear, concise and to the point.
# Notes
- Slide body should not use words like "This slide", "This presentation".
@ -19,6 +20,7 @@ system_prompt = """
- Provide query to search icon on "__icon_query__" property.
- Only use markdown to highlight important points.
- Make sure to follow language guidelines.
- Speaker note should be normal text, not markdown.
**Strictly follow the max and min character limit for every property in the slide.**
"""
@ -57,6 +59,18 @@ async def get_slide_content_from_type_and_outline(
response_schema = remove_fields_from_schema(
slide_layout.json_schema, ["__image_url__", "__icon_url__"]
)
response_schema = add_field_in_schema(
response_schema,
{
"__speaker_note__": {
"type": "string",
"minLength": 100,
"maxLength": 250,
"description": "Speaker note for the slide",
}
},
True,
)
response = await client.generate_structured(
model=model,

View file

@ -45,6 +45,48 @@ def remove_fields_from_schema(schema: dict, fields_to_remove: List[str]):
return schema
def add_field_in_schema(schema: dict, field: dict, required: bool = False) -> dict:
if not isinstance(field, dict) or len(field) != 1:
raise ValueError(
"`field` must be a dict with exactly one entry: {name: schema_dict}"
)
field_name, field_schema = next(iter(field.items()))
if not isinstance(field_name, str):
raise TypeError("Field name must be a string")
if not isinstance(field_schema, dict):
raise TypeError("Field schema must be a dictionary")
updated_schema: dict = deepcopy(schema)
root_properties = updated_schema.get("properties")
if not isinstance(root_properties, dict):
updated_schema["properties"] = {}
root_properties = updated_schema["properties"]
root_properties[field_name] = field_schema
# Update root-level required based on the flag
existing_required = updated_schema.get("required")
if not isinstance(existing_required, list):
existing_required = []
if required:
if field_name not in existing_required:
existing_required.append(field_name)
else:
if field_name in existing_required:
existing_required = [name for name in existing_required if name != field_name]
if existing_required:
updated_schema["required"] = existing_required
else:
updated_schema.pop("required", None)
return updated_schema
# From OpenAI
def ensure_strict_json_schema(
json_schema: object,

View file

@ -2,12 +2,15 @@
import { LayoutDashboard, Settings, Upload } from "lucide-react";
import React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
import { RootState } from "@/store/store";
import { useSelector } from "react-redux";
const HeaderNav = () => {
const canChangeKeys = useSelector((state: RootState) => state.userConfig.can_change_keys);
const pathname = usePathname();
return (
<div className="flex items-center gap-2">
@ -17,6 +20,7 @@ const HeaderNav = () => {
prefetch={false}
className="flex items-center gap-2 px-3 py-2 text-white hover:bg-primary/80 rounded-md transition-colors outline-none"
role="menuitem"
onClick={() => trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/dashboard" })}
>
<LayoutDashboard className="w-5 h-5" />
<span className="text-sm font-medium font-inter">
@ -29,6 +33,7 @@ const HeaderNav = () => {
prefetch={false}
className="flex items-center gap-2 px-3 py-2 text-white hover:bg-primary/80 rounded-md transition-colors outline-none"
role="menuitem"
onClick={() => trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/settings" })}
>
<Settings className="w-5 h-5" />
<span className="text-sm font-medium font-inter">

View file

@ -15,6 +15,7 @@ import { PresentationGenerationApi } from "../services/api/presentation-generati
import { Skeleton } from "@/components/ui/skeleton";
import { toast } from "sonner";
import { PreviousGeneratedImagesResponse } from "../services/api/params";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
interface ImageEditorProps {
initialImage: string | null;
imageIdx?: number;
@ -90,6 +91,7 @@ const ImageEditor = ({
const getPreviousGeneratedImage = async () => {
try {
trackEvent(MixpanelEvent.ImageEditor_GetPreviousGeneratedImages_API_Call);
const response =
await PresentationGenerationApi.getPreviousGeneratedImages();
setPreviousGeneratedImages(response);
@ -187,6 +189,7 @@ const ImageEditor = ({
try {
setIsGenerating(true);
setError(null);
trackEvent(MixpanelEvent.ImageEditor_GenerateImage_API_Call);
const response = await PresentationGenerationApi.generateImage({
prompt: prompt,
});
@ -228,6 +231,7 @@ const ImageEditor = ({
const formData = new FormData();
formData.append("file", file);
trackEvent(MixpanelEvent.ImageEditor_UploadImage_API_Call);
const response = await fetch("/api/upload-image", {
method: "POST",
body: formData,

View file

@ -2,7 +2,6 @@ import React from "react";
import { useDispatch } from "react-redux";
import { addNewSlide } from "@/store/slices/presentationGeneration";
import { Loader2 } from "lucide-react";
// import { useGroupLayoutLoader } from '@/app/layout-preview/hooks/useGroupLayoutLoader';
import { useLayout, FullDataInfo } from "../context/LayoutContext";
import { v4 as uuidv4 } from "uuid";
import { Trash2 } from 'lucide-react';

View file

@ -14,20 +14,7 @@ export const useSlideEdit = (
const [slideHtml, setSlideHtml] = useState("");
const slideContentRef = useRef<HTMLDivElement>(null);
// Load Tailwind CSS dynamically for slide content
useEffect(() => {
if (slide.processed && slide.html) {
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);
}
}
}, [slide.processed, slide.html]);
// Set up canvas when entering edit mode
useEffect(() => {

View file

@ -157,13 +157,23 @@ export const useSlideProcessing = (
setSlides(initialSlides);
const hasUnsupported = Array.isArray(pptxData.fonts?.not_supported_fonts) && pptxData.fonts.not_supported_fonts.length > 0;
toast.success(
`Template Processing Finished`,
{
description: `Please Upload the not supported fonts, and click Extract Template`
description: hasUnsupported
? `Please Upload the not supported fonts, and click Extract Template`
: `All fonts are supported. Starting template extraction...`
}
);
// If all fonts are supported, auto-start extraction from the first slide
if (!hasUnsupported && initialSlides.length > 0) {
const firstSlide = initialSlides[0];
setTimeout(() => processSlideToHtml(firstSlide, 0), 300);
}
} catch (error) {
console.error("Error processing file:", error);

View file

@ -1,6 +1,6 @@
"use client";
import React from "react";
import React, { useEffect } from "react";
import FontManager from "./components/FontManager";
import Header from "../dashboard/components/Header";
import { useLayout } from "../context/LayoutContext";
@ -10,16 +10,18 @@ import { useFileUpload } from "./hooks/useFileUpload";
import { useSlideProcessing } from "./hooks/useSlideProcessing";
import { useLayoutSaving } from "./hooks/useLayoutSaving";
import { useAPIKeyCheck } from "./hooks/useAPIKeyCheck";
import { useRouter } from "next/navigation";
import { useRouter, usePathname } from "next/navigation";
import { LoadingSpinner } from "./components/LoadingSpinner";
import { FileUploadSection } from "./components/FileUploadSection";
import { SaveLayoutButton } from "./components/SaveLayoutButton";
import { SaveLayoutModal } from "./components/SaveLayoutModal";
import EachSlide from "./components/EachSlide/NewEachSlide";
import { APIKeyWarning } from "./components/APIKeyWarning";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
const CustomTemplatePage = () => {
const router = useRouter();
const pathname = usePathname();
const { refetch } = useLayout();
// Custom hooks for different concerns
@ -42,6 +44,7 @@ const CustomTemplatePage = () => {
);
const handleSaveTemplate = async (layoutName: string, description: string): Promise<string | null> => {
trackEvent(MixpanelEvent.CustomTemplate_Save_Templates_API_Call);
const id = await saveLayout(layoutName, description);
if (id) {
router.push(`/template-preview/custom-${id}`);
@ -67,6 +70,17 @@ const CustomTemplatePage = () => {
)
);
};
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);
}
}, []);
// Loading state
if (isRequiredKeyLoading) {

View file

@ -7,6 +7,7 @@ import BackBtn from "@/components/BackBtn";
import { usePathname } from "next/navigation";
import HeaderNav from "@/app/(presentation-generator)/components/HeaderNab";
import { Layout, FilePlus2 } from "lucide-react";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
const Header = () => {
const pathname = usePathname();
return (
@ -15,7 +16,7 @@ const Header = () => {
<div className="flex items-center justify-between py-1">
<div className="flex items-center gap-3">
{pathname !== "/upload" && <BackBtn />}
<Link href="/dashboard">
<Link href="/dashboard" onClick={() => trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/dashboard" })}>
<img
src="/logo-white.png"
alt="Presentation logo"
@ -27,6 +28,7 @@ const Header = () => {
<Link
href="/custom-template"
prefetch={false}
onClick={() => trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/custom-template" })}
className="flex items-center gap-2 px-3 py-2 text-white hover:bg-primary/80 rounded-md transition-colors outline-none"
role="menuitem"
>
@ -36,6 +38,7 @@ const Header = () => {
<Link
href="/template-preview"
prefetch={false}
onClick={() => trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/template-preview" })}
className="flex items-center gap-2 px-3 py-2 text-white hover:bg-primary/80 rounded-md transition-colors outline-none"
role="menuitem"
>

View file

@ -19,7 +19,7 @@ import { OverlayLoader } from "@/components/ui/overlay-loader";
import { PresentationGenerationApi } from "../../services/api/presentation-generation";
import { setPresentationId } from "@/store/slices/presentationGeneration";
import { useDispatch, useSelector } from "react-redux";
import { useRouter } from "next/navigation";
import { useRouter, usePathname } from "next/navigation";
import { RootState } from "@/store/store";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
@ -28,6 +28,7 @@ import { getIconFromFile } from "../../utils/others";
import { ChevronRight, PanelRightOpen, X } from "lucide-react";
import ToolTip from "@/components/ToolTip";
import Header from "@/app/(presentation-generator)/dashboard/components/Header";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
// Types
interface LoadingState {
@ -50,6 +51,7 @@ const DocumentsPreviewPage: React.FC = () => {
// Hooks
const dispatch = useDispatch();
const router = useRouter();
const pathname = usePathname();
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Redux state
@ -144,6 +146,7 @@ const DocumentsPreviewPage: React.FC = () => {
const documentPaths = fileItems.map(
(fileItem: FileItem) => fileItem.file_path
);
trackEvent(MixpanelEvent.DocumentsPreview_Create_Presentation_API_Call);
const createResponse = await PresentationGenerationApi.createPresentation(
{
prompt: config?.prompt ?? "",
@ -154,6 +157,7 @@ const DocumentsPreviewPage: React.FC = () => {
);
dispatch(setPresentationId(createResponse.id));
trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/outline" });
router.replace("/outline");
} catch (error: any) {
console.error("Error in radar presentation creation:", error);

View file

@ -1,4 +1,6 @@
import React from "react";
import { usePathname } from "next/navigation";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
import { Button } from "@/components/ui/button";
import { LoadingState, LayoutGroup } from "../types/index";
@ -15,6 +17,8 @@ const GenerateButton: React.FC<GenerateButtonProps> = ({
selectedLayoutGroup,
onSubmit
}) => {
const pathname = usePathname();
const isDisabled =
loadingState.isLoading ||
streamState.isLoading ||
@ -30,7 +34,16 @@ const GenerateButton: React.FC<GenerateButtonProps> = ({
return (
<Button
disabled={isDisabled}
onClick={onSubmit}
onClick={() => {
if (!streamState.isLoading && !streamState.isStreaming) {
if (!selectedLayoutGroup) {
trackEvent(MixpanelEvent.Outline_Select_Template_Button_Clicked, { pathname });
} else {
trackEvent(MixpanelEvent.Outline_Generate_Presentation_Button_Clicked, { pathname });
}
}
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"
>
<svg

View file

@ -1,5 +1,7 @@
import { CheckCircle } from "lucide-react";
import React from "react";
import { usePathname } from "next/navigation";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
import { LayoutGroup } from "../types/index";
import { useLayout } from "../../context/LayoutContext";
import { useFontLoader } from "../../hooks/useFontLoader";
@ -18,9 +20,13 @@ const GroupLayouts: React.FC<GroupLayoutsProps> = ({
const layoutGroup = getFullDataByGroup(group.id);
const fonts = getCustomTemplateFonts(group.id.split("custom-")[1]);
useFontLoader(fonts || []);
const pathname = usePathname();
return (
<div
onClick={() => onSelectLayoutGroup(group)}
onClick={() => {
trackEvent(MixpanelEvent.Group_Layout_Selected_Clicked, { pathname });
onSelectLayoutGroup(group);
}}
className={`relative p-4 rounded-lg border cursor-pointer transition-all duration-200 ${
selectedLayoutGroup?.id === group.id
? "border-blue-500 bg-blue-50 shadow-md"

View file

@ -16,6 +16,8 @@ import {
import { OutlineItem } from "./OutlineItem";
import { Button } from "@/components/ui/button";
import { FileText } from "lucide-react";
import { usePathname } from "next/navigation";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
interface OutlineContentProps {
outlines: { content: string }[] | null;
@ -39,6 +41,8 @@ const OutlineContent: React.FC<OutlineContentProps> = ({
})
);
const pathname = usePathname();
return (
<div className="space-y-6 font-instrument_sans">
{/* <div className="flex items-center justify-between">
@ -110,7 +114,10 @@ const OutlineContent: React.FC<OutlineContentProps> = ({
<Button
variant="outline"
onClick={onAddSlide}
onClick={() => {
trackEvent(MixpanelEvent.Outline_Add_Slide_Button_Clicked, { pathname });
onAddSlide();
}}
disabled={isLoading || isStreaming}
className="w-full my-4 text-blue-600 border-blue-200"
>
@ -126,7 +133,10 @@ const OutlineContent: React.FC<OutlineContentProps> = ({
<p className="text-gray-600 mb-4">No outlines available</p>
<Button
variant="outline"
onClick={onAddSlide}
onClick={() => {
trackEvent(MixpanelEvent.Outline_Add_Slide_Button_Clicked, { pathname });
onAddSlide();
}}
className="text-blue-600 border-blue-200"
>
+ Add First Slide

View file

@ -5,6 +5,7 @@ import { toast } from "sonner";
import { clearPresentationData } from "@/store/slices/presentationGeneration";
import { PresentationGenerationApi } from "../../services/api/presentation-generation";
import { LayoutGroup, LoadingState, TABS } from "../types/index";
import { MixpanelEvent, trackEvent } from "@/utils/mixpanel";
const DEFAULT_LOADING_STATE: LoadingState = {
message: "",
@ -37,7 +38,7 @@ export const usePresentationGeneration = (
});
return false;
}
if(!selectedLayoutGroup.slides.length){
if (!selectedLayoutGroup.slides.length) {
toast.error("No Slide Schema found", {
description: "Please select a Group before generating presentation",
});
@ -63,7 +64,7 @@ export const usePresentationGeneration = (
}
if (!validateInputs()) return;
setLoadingState({
message: "Generating presentation data...",
@ -74,8 +75,9 @@ export const usePresentationGeneration = (
try {
const layoutData = prepareLayoutData();
if (!layoutData) return;
trackEvent(MixpanelEvent.Presentation_Prepare_API_Call);
const response = await PresentationGenerationApi.presentationPrepare({
presentation_id: presentationId,
outlines: outlines,
@ -84,7 +86,7 @@ export const usePresentationGeneration = (
if (response) {
dispatch(clearPresentationData());
router.replace(`/presentation?id=${presentationId}&stream=true`);
router.replace(`/presentation?id=${presentationId}&stream=true`);
}
} catch (error: any) {
console.error('Error In Presentation Generation(prepare).', error);

View file

@ -5,21 +5,35 @@ import { RootState } from "@/store/store";
import { Skeleton } from "@/components/ui/skeleton";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { usePathname } from "next/navigation";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
import { AlertCircle } from "lucide-react";
import { useGroupLayouts } from "../hooks/useGroupLayouts";
import { setPresentationData } from "@/store/slices/presentationGeneration";
import { DashboardApi } from "../services/api/dashboard";
import { useLayout } from "../context/LayoutContext";
import { useFontLoader } from "../hooks/useFontLoader";
const PresentationPage = ({ presentation_id }: { presentation_id: string }) => {
const { renderSlideContent, loading } = useGroupLayouts();
const pathname = usePathname();
const [contentLoading, setContentLoading] = useState(true);
const { getCustomTemplateFonts } = useLayout()
const dispatch = useDispatch();
const { presentationData } = useSelector(
(state: RootState) => state.presentationGeneration
);
const [error, setError] = useState(false);
useEffect(() => {
if (!loading && presentationData?.slides && presentationData?.slides.length > 0) {
const presentation_id = presentationData?.slides[0].layout.split(":")[0].split("custom-")[1];
const fonts = getCustomTemplateFonts(presentation_id);
useFontLoader(fonts || []);
}
}, [presentationData, loading]);
useEffect(() => {
if (presentationData?.slides[0].layout.includes("custom")) {
const existingScript = document.querySelector(
@ -51,6 +65,7 @@ const PresentationPage = ({ presentation_id }: { presentation_id: string }) => {
setContentLoading(false);
}
};
// Regular view
return (
<div className="flex overflow-hidden flex-col">
@ -70,7 +85,10 @@ const PresentationPage = ({ presentation_id }: { presentation_id: string }) => {
</p>
<Button
className="mt-4 bg-red-500 text-white hover:bg-red-600 focus:ring-4 focus:ring-red-300"
onClick={() => window.location.reload()}
onClick={() => {
trackEvent(MixpanelEvent.PdfMaker_Retry_Button_Clicked, { pathname });
window.location.reload();
}}
>
Retry
</Button>
@ -83,10 +101,10 @@ const PresentationPage = ({ presentation_id }: { presentation_id: string }) => {
className="mx-auto flex flex-col items-center overflow-hidden justify-center "
>
{!presentationData ||
loading ||
contentLoading ||
!presentationData?.slides ||
presentationData?.slides.length === 0 ? (
loading ||
contentLoading ||
!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) => (
@ -103,7 +121,8 @@ const PresentationPage = ({ presentation_id }: { presentation_id: string }) => {
presentationData.slides &&
presentationData.slides.length > 0 &&
presentationData.slides.map((slide: any, index: number) => (
<div key={index} className="w-full">
// [data-speaker-note] is used to extract the speaker note from the slide for export to pptx
<div key={index} className="w-full" data-speaker-note={slide.note}>
{renderSlideContent(slide, true)}
</div>
))}

View file

@ -7,7 +7,7 @@ import {
} from "lucide-react";
import React, { useState } from "react";
import Wrapper from "@/components/Wrapper";
import { useRouter } from "next/navigation";
import { useRouter, usePathname } from "next/navigation";
import {
Popover,
PopoverContent,
@ -29,6 +29,7 @@ import HeaderNav from "../../components/HeaderNab";
import PDFIMAGE from "@/public/pdf.svg";
import PPTXIMAGE from "@/public/pptx.svg";
import Image from "next/image";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
const Header = ({
presentation_id,
@ -40,6 +41,7 @@ const Header = ({
const [open, setOpen] = useState(false);
const [showLoader, setShowLoader] = useState(false);
const router = useRouter();
const pathname = usePathname();
const { presentationData, isStreaming } = useSelector(
@ -59,13 +61,16 @@ const Header = ({
setOpen(false);
setShowLoader(true);
// Save the presentation data before exporting
trackEvent(MixpanelEvent.Header_UpdatePresentationContent_API_Call);
await PresentationGenerationApi.updatePresentationContent(presentationData);
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');
@ -92,8 +97,10 @@ const Header = ({
setOpen(false);
setShowLoader(true);
// Save the presentation data before exporting
trackEvent(MixpanelEvent.Header_UpdatePresentationContent_API_Call);
await PresentationGenerationApi.updatePresentationContent(presentationData);
trackEvent(MixpanelEvent.Header_ExportAsPDF_API_Call);
const response = await fetch('/api/export-as-pdf', {
method: 'POST',
body: JSON.stringify({
@ -136,14 +143,20 @@ const Header = ({
const ExportOptions = ({ mobile }: { mobile: boolean }) => (
<div className={`space-y-2 max-md:mt-4 ${mobile ? "" : "bg-white"} rounded-lg`}>
<Button
onClick={handleExportPdf}
onClick={() => {
trackEvent(MixpanelEvent.Header_Export_PDF_Button_Clicked, { pathname });
handleExportPdf();
}}
variant="ghost"
className={`pb-4 border-b rounded-none border-gray-300 w-full flex justify-start text-[#5146E5] ${mobile ? "bg-white py-6 border-none rounded-lg" : ""}`} >
<Image src={PDFIMAGE} alt="pdf export" width={30} height={30} />
Export as PDF
</Button>
<Button
onClick={handleExportPptx}
onClick={() => {
trackEvent(MixpanelEvent.Header_Export_PPTX_Button_Clicked, { pathname });
handleExportPptx();
}}
variant="ghost"
className={`w-full flex justify-start text-[#5146E5] ${mobile ? "bg-white py-6" : ""}`}
>
@ -159,7 +172,11 @@ const Header = ({
<div className="flex flex-col lg:flex-row items-center gap-4">
{/* Present Button */}
<Button
onClick={() => router.push(`?id=${presentation_id}&mode=present&slide=${currentSlide || 0}`)}
onClick={() => {
const to = `?id=${presentation_id}&mode=present&slide=${currentSlide || 0}`;
trackEvent(MixpanelEvent.Navigation, { from: pathname, to });
router.push(to);
}}
variant="ghost"
className="border border-white font-bold text-white rounded-[32px] transition-all duration-300 group"
>

View file

@ -8,6 +8,8 @@ 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 {
@ -23,6 +25,7 @@ import { useFontLoader } from "../../hooks/useFontLoader";
const PresentationPage: React.FC<PresentationPageProps> = ({
presentation_id,
}) => {
const pathname = usePathname();
// State management
const [loading, setLoading] = useState(true);
const [selectedSlide, setSelectedSlide] = useState(0);
@ -110,7 +113,7 @@ const PresentationPage: React.FC<PresentationPageProps> = ({
<p className="text-center mb-4">
We couldn't load your presentation. Please try again.
</p>
<Button onClick={() => window.location.reload()}>Refresh Page</Button>
<Button onClick={() => { trackEvent(MixpanelEvent.PresentationPage_Refresh_Page_Button_Clicked, { pathname }); window.location.reload(); }}>Refresh Page</Button>
</div>
</div>
);

View file

@ -17,6 +17,8 @@ import {
updateSlide,
} from "@/store/slices/presentationGeneration";
import { useGroupLayouts } from "../../hooks/useGroupLayouts";
import { usePathname } from "next/navigation";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
import NewSlide from "../../components/NewSlide";
interface SlideContentProps {
@ -35,6 +37,7 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => {
// Use the centralized group layouts hook
const { renderSlideContent, loading } = useGroupLayouts();
const pathname = usePathname();
const handleSubmit = async () => {
const element = document.getElementById(
@ -48,6 +51,7 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => {
setIsUpdating(true);
try {
trackEvent(MixpanelEvent.Slide_Edit_API_Call);
const response = await PresentationGenerationApi.editSlide(
slide.id,
value
@ -68,6 +72,7 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => {
};
const onDeleteSlide = async () => {
try {
trackEvent(MixpanelEvent.Slide_Delete_API_Call);
dispatch(deletePresentationSlide(slide.index));
} catch (error: any) {
console.error("Error deleting slide:", error);
@ -108,7 +113,7 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => {
return;
}
if (slide.layout.includes("custom")) {
const existingScript = document.querySelector(
'script[src*="tailwindcss.com"]'
);
@ -149,7 +154,10 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => {
<ToolTip content="Add new slide below">
{!isStreaming && !loading && (
<div
onClick={() => setShowNewSlideSelection(true)}
onClick={() => {
trackEvent(MixpanelEvent.Slide_Add_New_Slide_Button_Clicked, { pathname });
setShowNewSlideSelection(true);
}}
className=" bg-white shadow-md w-[80px] py-2 border hover:border-[#5141e5] duration-300 flex items-center justify-center rounded-lg cursor-pointer mx-auto"
>
<PlusIcon className="text-gray-500 text-base cursor-pointer" />
@ -169,7 +177,10 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => {
{!isStreaming && !loading && (
<ToolTip content="Delete slide">
<div
onClick={onDeleteSlide}
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" />
@ -219,9 +230,11 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => {
<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" : ""
}`}
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" />

View file

@ -7,6 +7,7 @@ import {
} from "@/store/slices/presentationGeneration";
import { jsonrepair } from "jsonrepair";
import { toast } from "sonner";
import { MixpanelEvent, trackEvent } from "@/utils/mixpanel";
export const usePresentationStreaming = (
presentationId: string,
@ -26,6 +27,8 @@ export const usePresentationStreaming = (
dispatch(setStreaming(true));
dispatch(clearPresentationData());
trackEvent(MixpanelEvent.Presentation_Stream_API_Call);
eventSource = new EventSource(
`/api/v1/ppt/presentation/stream?presentation_id=${presentationId}`
);
@ -99,7 +102,7 @@ export const usePresentationStreaming = (
setLoading(false);
dispatch(setStreaming(false));
setError(true);
break;
break;
}
});

View file

@ -9,10 +9,11 @@ import {
checkIfSelectedOllamaModelIsPulled,
pullOllamaModel,
} from "@/utils/providerUtils";
import { useRouter } from "next/navigation";
import { useRouter, usePathname } from "next/navigation";
import LLMProviderSelection from "@/components/LLMSelection";
import Header from "../dashboard/components/Header";
import { LLMConfig } from "@/types/llm_config";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
// Button state interface
interface ButtonState {
@ -26,6 +27,7 @@ interface ButtonState {
const SettingsPage = () => {
const router = useRouter();
const pathname = usePathname();
const userConfigState = useSelector((state: RootState) => state.userConfig);
const [llmConfig, setLlmConfig] = useState<LLMConfig>(
userConfigState.llm_config
@ -61,6 +63,7 @@ const SettingsPage = () => {
}, [downloadingModel?.downloaded, downloadingModel?.size]);
const handleSaveConfig = async () => {
trackEvent(MixpanelEvent.Settings_SaveConfiguration_Button_Clicked, { pathname });
try {
setButtonState(prev => ({
...prev,
@ -68,13 +71,16 @@ const SettingsPage = () => {
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();
}
}
@ -85,6 +91,7 @@ const SettingsPage = () => {
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");

View file

@ -1,400 +0,0 @@
"use client";
import React, { useEffect, useState, useRef } from "react";
import { useParams, useRouter } from "next/navigation";
// import { useGroupLayoutLoader } from '../hooks/useGroupLayoutLoader'
import LoadingStates from "../components/LoadingStates";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ArrowLeft, Edit, Home, Trash2 } from "lucide-react";
import { useLayout } from "@/app/(presentation-generator)/context/LayoutContext";
import html2canvas from "html2canvas";
import { EditControls } from "../../custom-template/components/EachSlide/EditControls";
import { useDrawingCanvas } from "../../custom-template/hooks/useDrawingCanvas";
const GroupLayoutPreview = () => {
const params = useParams();
const router = useRouter();
const slug = params.slug as string;
// const isCustom = slug.includes("custom-");
const isCustom = true;
// Custom hooks
const {
canvasRef,
slideDisplayRef,
strokeWidth,
strokeColor,
eraserMode,
isDrawing,
canvasDimensions,
setCanvasDimensions,
didYourDraw,
handleMouseDown,
handleMouseMove,
handleMouseUp,
handleTouchStart,
handleTouchMove,
handleTouchEnd,
handleClearCanvas,
handleEraserModeChange,
handleStrokeColorChange,
handleStrokeWidthChange,
} = useDrawingCanvas();
const slideContentRef = useRef<HTMLDivElement | null>(null);
const { getFullDataByGroup, loading,refetch } = useLayout();
const layoutGroup = getFullDataByGroup(slug);
const [isEditMode, setIsEditMode] = useState(false);
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const [prompt, setPrompt] = useState("");
const [isUpdating, setIsUpdating] = useState(false);
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);
}
}, [slug]);
// Size canvas to content when entering edit mode
useEffect(() => {
if (isEditMode && slideContentRef.current) {
const rect = slideContentRef.current.getBoundingClientRect();
setCanvasDimensions({
width: Math.max(rect.width, 800),
height: Math.max(rect.height, 600),
});
}
}, [isEditMode, setCanvasDimensions]);
// Handle loading state
if (loading) {
return <LoadingStates type="loading" />;
}
// Handle empty state
if (!layoutGroup || layoutGroup.length === 0) {
return <LoadingStates type="empty" />;
}
const deleteLayouts = async () => {
const presentationId = slug.replace('custom-','');
refetch();
router.back();
const response = await fetch(`/api/v1/ppt/template-management/delete-templates/${presentationId}`, {
method: "DELETE",
});
if (response.ok) {
router.push("/template-preview");
}
}
const handleSave = async (
slideDisplayRef: React.RefObject<HTMLDivElement |null>,
didYourDraw: boolean
) => {
if (
!slideContentRef.current ||
!slideDisplayRef.current
)
return;
if (!prompt.trim()) {
alert("Please enter a prompt before saving.");
return;
}
setIsUpdating(true);
try {
// Take screenshot of the slide display area (slide only)
const slideOnly = await html2canvas(slideDisplayRef.current, {
backgroundColor: "#ffffff",
scale: 1,
logging: false,
useCORS: true,
ignoreElements: (element) => {
return element.tagName === "CANVAS";
},
});
let slideWithCanvas;
if (didYourDraw) {
// Take screenshot of the entire slide display area including canvas
slideWithCanvas = await html2canvas(slideDisplayRef.current, {
backgroundColor: "#ffffff",
scale: 1,
logging: false,
useCORS: true,
});
}
const currentUiImageBlob = dataURLToBlob(
slideOnly.toDataURL("image/png")
);
let sketchImageBlob;
if (didYourDraw && slideWithCanvas) {
sketchImageBlob = dataURLToBlob(slideWithCanvas.toDataURL("image/png"));
}
// download the images
const currentUiImageUrl = URL.createObjectURL(currentUiImageBlob);
if (currentUiImageUrl) {
const a = document.createElement("a");
a.href = currentUiImageUrl;
a.download = `slide-current.png`;
a.click();
}
if (sketchImageBlob) {
const sketchImageUrl = URL.createObjectURL(sketchImageBlob);
if (sketchImageUrl) {
const b = document.createElement("a");
b.href = sketchImageUrl;
b.download = `slide-sketch.png`;
b.click();
}
}
// const formData = new FormData();
// formData.append(
// "current_ui_image",
// currentUiImageBlob,
// `slide--current.png`
// );
// if (didYourDraw && slideWithCanvas && sketchImageBlob) {
// formData.append(
// "sketch_image",
// sketchImageBlob,
// `slide-sketch.png`
// );
// }
// formData.append("html", '');
// formData.append("prompt", prompt);
// const response = await fetch("/api/v1/ppt/html-edit/", {
// method: "POST",
// body: formData,
// });
// if (!response.ok) {
// throw new Error(`API call failed: ${response.statusText}`);
// }
// const data = await response.json();
// Exit edit mode
setIsEditMode(false);
setPrompt("");
} catch (error) {
console.error("Error updating slide:", error);
alert(
`Error updating slide: ${
error instanceof Error ? error.message : "Unknown error"
}`
);
} finally {
setIsUpdating(false);
}
};
const dataURLToBlob = (dataURL: string): Blob => {
const parts = dataURL.split(",");
const contentType = parts[0].match(/:(.*?);/)?.[1] || "image/png";
const raw = window.atob(parts[1]);
const rawLength = raw.length;
const uInt8Array = new Uint8Array(rawLength);
for (let i = 0; i < rawLength; ++i) {
uInt8Array[i] = raw.charCodeAt(i);
}
return new Blob([uInt8Array], { type: contentType });
};
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white shadow-sm border-b sticky top-0 z-30">
<div className="max-w-7xl mx-auto px-6 py-6">
{/* Navigation */}
<div className="flex items-center gap-4 mb-4">
<Button
variant="outline"
size="sm"
onClick={() => router.back()}
className="flex items-center gap-2"
>
<ArrowLeft className="w-4 h-4" />
Back
</Button>
<Button
variant="outline"
size="sm"
onClick={() => router.push("/template-preview")}
className="flex items-center gap-2"
>
<Home className="w-4 h-4" />
All Groups
</Button>
{isCustom && <button className=" border border-red-200 flex justify-center items-center gap-2 text-red-700 px-4 py-1 rounded-md" onClick={() => {
deleteLayouts();
}}><Trash2 className="w-4 h-4" />Delete</button>}
</div>
<div className="text-center">
<h1 className="text-3xl font-bold text-gray-900 capitalize">
{layoutGroup[0].groupName} Layouts
</h1>
<p className="text-gray-600 mt-2">
{layoutGroup.length} layout{layoutGroup.length !== 1 ? "s" : ""} {" "}
{layoutGroup[0].groupName}
</p>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-6 py-8">
{/* Edit Controls (no HTML editor) */}
{isCustom && (
<EditControls
isEditMode={isEditMode}
prompt={prompt}
isUpdating={isUpdating}
strokeWidth={strokeWidth}
strokeColor={strokeColor}
eraserMode={eraserMode}
onPromptChange={setPrompt}
onSave={() => {
setIsUpdating(true);
setTimeout(() => {
setIsUpdating(false);
setIsEditMode(false);
setSelectedIndex(null);
}, 300);
}}
onCancel={() => {
setIsEditMode(false);
setSelectedIndex(null);
handleClearCanvas();
}}
onStrokeWidthChange={handleStrokeWidthChange}
onStrokeColorChange={handleStrokeColorChange}
onEraserModeChange={handleEraserModeChange}
onClearCanvas={handleClearCanvas}
/>
)}
<div className="space-y-8">
{layoutGroup.map((layout: any, index: number) => {
const {
component: LayoutComponent,
sampleData,
name,
fileName,
} = layout;
const isSelected = isCustom && isEditMode && selectedIndex === index;
return (
<Card
key={`${layoutGroup[0].groupName}-${index}`}
className="overflow-hidden shadow-md hover:shadow-lg transition-shadow"
>
{/* Layout Header */}
<div className="bg-white px-6 py-4 border-b">
<div className="flex items-center justify-between">
<div>
<h3 className="text-xl font-semibold text-gray-900">
{name}
</h3>
<div className="flex items-center gap-4 mt-1">
<span className="text-sm text-gray-500 font-mono">
{fileName}
</span>
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{layoutGroup[0].groupName}
</span>
</div>
</div>
<div className="text-right">
{isCustom && (
<button
className="border flex items-center gap-2 border-blue-400 bg-blue-50 px-4 py-1 rounded-md text-blue-700"
onClick={() => {
setIsEditMode(true);
setSelectedIndex(index);
}}
>
<Edit className="w-4 h-4" />Edit
</button>
)}
</div>
</div>
</div>
{/* Layout Content */}
<div ref={isSelected ? slideDisplayRef : undefined} className="relative mx-auto w-full">
<div
ref={isSelected ? slideContentRef : undefined}
className="bg-gray-50 aspect-video max-w-[1280px] w-full"
>
<LayoutComponent data={sampleData} />
{isSelected && (
<canvas
ref={canvasRef!}
width={canvasDimensions.width}
height={canvasDimensions.height}
style={{
position: "absolute",
top: 0,
left: 0,
zIndex: 30,
cursor: eraserMode ? "grab" : "crosshair",
pointerEvents: "auto",
touchAction: "none",
}}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onContextMenu={(e) => e.preventDefault()}
/>
)}
</div>
</div>
</Card>
);
})}
</div>
</main>
{/* Footer */}
<footer className="bg-white border-t mt-16">
<div className="max-w-7xl mx-auto px-6 py-8">
<div className="text-center text-gray-600">
<p>
{layoutGroup[0].groupName} {layoutGroup.length} components
</p>
</div>
</div>
</footer>
</div>
);
};
export default GroupLayoutPreview;

View file

@ -1,7 +1,6 @@
"use client";
import React, { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
// import { useGroupLayoutLoader } from '../hooks/useGroupLayoutLoader'
import { useParams, useRouter, usePathname } from "next/navigation";
import LoadingStates from "../components/LoadingStates";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@ -14,11 +13,14 @@ import "prismjs/components/prism-javascript";
import "prismjs/components/prism-markup";
import "prismjs/components/prism-jsx";
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { useFontLoader } from "../../hooks/useFontLoader";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
const GroupLayoutPreview = () => {
const params = useParams();
const router = useRouter();
const slug = params.slug as string;
const pathname = usePathname();
const { getFullDataByGroup, loading, refetch } = useLayout();
const layoutGroup = getFullDataByGroup(slug);
@ -35,22 +37,7 @@ const GroupLayoutPreview = () => {
const [layoutsMap, setLayoutsMap] = useState<Record<string, { layout_id: string; layout_name: string; layout_code: string; fonts?: string[] }>>({});
const [templateMeta, setTemplateMeta] = useState<{ name?: string; description?: string } | null>(null);
const injectFonts = (fontUrls: string[]) => {
fontUrls.forEach((fontUrl) => {
if (!fontUrl) return;
const existingStyle = document.querySelector(`style[data-font-url="${fontUrl}"]`);
if (existingStyle) return;
const fileName = fontUrl.split("/").pop() || "CustomFont";
const baseName = fileName.replace(/\.[a-zA-Z0-9]+$/, "");
const fontFamily = baseName.replace(/[^A-Za-z0-9_-]/g, "_");
const ext = (fileName.split(".").pop() || "ttf").toLowerCase();
const format = ext === "otf" ? "opentype" : ext === "woff" ? "woff" : ext === "woff2" ? "woff2" : "truetype";
const style = document.createElement("style");
style.setAttribute("data-font-url", fontUrl);
style.textContent = `@font-face { font-family: '${fontFamily}'; src: url('${fontUrl}') format('${format}'); font-display: swap; }`;
document.head.appendChild(style);
});
};
useEffect(() => {
const loadCustomLayouts = async () => {
@ -74,7 +61,7 @@ const GroupLayoutPreview = () => {
setTemplateMeta({ name: data.template.name, description: data.template.description });
}
if (Array.isArray(data?.fonts) && data.fonts.length) {
injectFonts(data.fonts);
useFontLoader(data.fonts);
}
} catch (e) {
// noop
@ -102,7 +89,7 @@ const GroupLayoutPreview = () => {
Object.values(layoutsMap).forEach((entry) => {
(entry.fonts || []).forEach((f) => allFonts.push(f));
});
if (allFonts.length) injectFonts(allFonts);
if (allFonts.length) useFontLoader(allFonts);
}, [layoutsMap, isCustom]);
// Handle loading state
@ -134,7 +121,7 @@ const GroupLayoutPreview = () => {
setCurrentCode(entry.layout_code || "");
setCurrentFonts(entry.fonts);
// Make sure fonts for this layout are loaded before editing
injectFonts(entry.fonts || []);
useFontLoader(entry.fonts || []);
setEditorOpen(true);
};
@ -192,7 +179,10 @@ const GroupLayoutPreview = () => {
<Button
variant="outline"
size="sm"
onClick={() => router.back()}
onClick={() => {
trackEvent(MixpanelEvent.TemplatePreview_Back_Button_Clicked, { pathname });
router.back();
}}
className="flex items-center gap-2"
>
<ArrowLeft className="w-4 h-4" />
@ -201,13 +191,18 @@ const GroupLayoutPreview = () => {
<Button
variant="outline"
size="sm"
onClick={() => router.push("/template-preview")}
onClick={() => {
trackEvent(MixpanelEvent.TemplatePreview_All_Groups_Button_Clicked, { pathname });
router.push("/template-preview");
}}
className="flex items-center gap-2"
>
<Home className="w-4 h-4" />
All Groups
</Button>
{slug.includes('custom-') && <button className=" border border-red-200 flex justify-center items-center gap-2 text-red-700 px-4 py-1 rounded-md" onClick={() => {
trackEvent(MixpanelEvent.TemplatePreview_Delete_Templates_Button_Clicked, { pathname });
trackEvent(MixpanelEvent.TemplatePreview_Delete_Templates_API_Call);
deleteLayouts();
}}><Trash2 className="w-4 h-4" />Delete</button>}
</div>
@ -265,7 +260,10 @@ const GroupLayoutPreview = () => {
variant="outline"
size="sm"
className="flex items-center gap-2 bg-blue-50 border border-blue-400 text-blue-700"
onClick={() => openEditor(fileName)}
onClick={() => {
trackEvent(MixpanelEvent.TemplatePreview_Open_Editor_Button_Clicked, { pathname });
openEditor(fileName);
}}
disabled={!layoutsMap[fileName]}
title={!layoutsMap[fileName] ? "Loading layout code..." : "Edit layout code"}
>

View file

@ -1,11 +1,12 @@
"use client";
import React, { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useRouter, usePathname } from "next/navigation";
import LoadingStates from "./components/LoadingStates";
import { Card } from "@/components/ui/card";
import { ExternalLink } from "lucide-react";
import Header from "@/app/(presentation-generator)/dashboard/components/Header";
import { useLayout } from "../context/LayoutContext";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
const LayoutPreview = () => {
const {
@ -17,6 +18,7 @@ const LayoutPreview = () => {
error,
} = useLayout();
const router = useRouter();
const pathname = usePathname();
const [summaryMap, setSummaryMap] = useState<Record<string, { lastUpdatedAt?: number; name?: string; description?: string }>>({});
@ -114,7 +116,10 @@ const LayoutPreview = () => {
<Card
key={group.groupName}
className="cursor-pointer hover:shadow-md transition-all duration-200 group"
onClick={() => router.push(`/template-preview/${group.groupName}`)}
onClick={() => {
trackEvent(MixpanelEvent.Navigation, { from: pathname, to: `/template-preview/${group.groupName}` });
router.push(`/template-preview/${group.groupName}`)
}}
>
<div className="p-6">
<div className="flex items-center justify-between mb-3">
@ -166,7 +171,10 @@ const LayoutPreview = () => {
<Card
key={group.groupName}
className="cursor-pointer hover:shadow-md transition-all duration-200 group"
onClick={() => router.push(`/template-preview/${group.groupName}`)}
onClick={() => {
trackEvent(MixpanelEvent.Navigation, { from: pathname, to: `/template-preview/${group.groupName}` });
router.push(`/template-preview/${group.groupName}`)
}}
>
<div className="p-6">
<div className="flex items-center justify-between mb-3">
@ -196,7 +204,10 @@ const LayoutPreview = () => {
) : (
<Card
className="cursor-pointer hover:shadow-md transition-all border-blue-500 duration-200 group"
onClick={() => router.push(`/custom-template`)}
onClick={() => {
trackEvent(MixpanelEvent.Navigation, { from: pathname, to: `/custom-template` });
router.push(`/custom-template`)
}}
>
<div className="p-6">
<div className="flex items-center justify-between mb-3">

View file

@ -11,7 +11,7 @@
"use client";
import React, { useState } from "react";
import { useRouter } from "next/navigation";
import { useRouter, usePathname } from "next/navigation";
import { useDispatch } from "react-redux";
import { clearOutlines, setPresentationId } from "@/store/slices/presentationGeneration";
import { ConfigurationSelects } from "./ConfigurationSelects";
@ -25,6 +25,7 @@ import { PresentationGenerationApi } from "../../services/api/presentation-gener
import { OverlayLoader } from "@/components/ui/overlay-loader";
import Wrapper from "@/components/Wrapper";
import { setPptGenUploadState } from "@/store/slices/presentationGenUpload";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
// Types for loading state
interface LoadingState {
@ -37,6 +38,7 @@ interface LoadingState {
const UploadPage = () => {
const router = useRouter();
const pathname = usePathname();
const dispatch = useDispatch();
// State management
@ -115,6 +117,7 @@ const UploadPage = () => {
let documents = [];
if (files.length > 0) {
trackEvent(MixpanelEvent.Upload_Upload_Documents_API_Call);
const uploadResponse = await PresentationGenerationApi.uploadDoc(files);
documents = uploadResponse;
}
@ -122,9 +125,8 @@ const UploadPage = () => {
const promises: Promise<any>[] = [];
if (documents.length > 0) {
promises.push(
PresentationGenerationApi.decomposeDocuments(documents)
);
trackEvent(MixpanelEvent.Upload_Decompose_Documents_API_Call);
promises.push(PresentationGenerationApi.decomposeDocuments(documents));
}
const responses = await Promise.all(promises);
dispatch(setPptGenUploadState({
@ -132,6 +134,7 @@ const UploadPage = () => {
files: responses,
}));
dispatch(clearOutlines())
trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/documents-preview" });
router.push("/documents-preview");
};
@ -147,6 +150,7 @@ const UploadPage = () => {
});
// Use the first available layout group for direct generation
trackEvent(MixpanelEvent.Upload_Create_Presentation_API_Call);
const createResponse = await PresentationGenerationApi.createPresentation({
prompt: config?.prompt ?? "",
n_slides: config?.slides ? parseInt(config.slides) : null,
@ -156,6 +160,7 @@ const UploadPage = () => {
dispatch(setPresentationId(createResponse.id));
dispatch(clearOutlines())
trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/outline" });
router.push("/outline");
};

View file

@ -0,0 +1,24 @@
'use client';
import { useEffect } from 'react';
import { initMixpanel, MixpanelEvent, trackEvent } from '@/utils/mixpanel';
import { usePathname } from 'next/navigation';
export function MixpanelInitializer({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
// Initialize once
useEffect(() => {
initMixpanel();
}, []);
useEffect(() => {
trackEvent(MixpanelEvent.PageView, { url: pathname });
}, [pathname]);
return <>{children}</>;
}
export default MixpanelInitializer;

View file

@ -31,9 +31,9 @@ export async function GET(request: NextRequest) {
[browser, page] = await getBrowserAndPage(id);
const screenshotsDir = getScreenshotsDir();
const slides = await getSlides(page);
const { slides, speakerNotes } = await getSlidesAndSpeakerNotes(page);
const slides_attributes = await getSlidesAttributes(slides, screenshotsDir);
await postProcessSlidesAttributes(slides_attributes, screenshotsDir);
await postProcessSlidesAttributes(slides_attributes, screenshotsDir, speakerNotes);
const slides_pptx_models = convertElementAttributesToPptxSlides(slides_attributes);
const presentation_pptx_model: PptxPresentationModel = {
slides: slides_pptx_models,
@ -100,8 +100,8 @@ function getScreenshotsDir() {
return screenshotsDir;
}
async function postProcessSlidesAttributes(slidesAttributes: SlideAttributesResult[], screenshotsDir: string) {
for (const slideAttributes of slidesAttributes) {
async function postProcessSlidesAttributes(slidesAttributes: SlideAttributesResult[], screenshotsDir: string, speakerNotes: string[]) {
for (const [index, slideAttributes] of slidesAttributes.entries()) {
for (const element of slideAttributes.elements) {
if (element.should_screenshot) {
const screenshotPath = await screenshotElement(element, screenshotsDir);
@ -111,6 +111,7 @@ async function postProcessSlidesAttributes(slidesAttributes: SlideAttributesResu
element.element = undefined;
}
}
slideAttributes.speakerNote = speakerNotes[index];
}
}
@ -190,15 +191,15 @@ async function getSlidesAttributes(slides: ElementHandle<Element>[], screenshots
const slideAttributes = await Promise.all(
slides.map((slide) => getAllChildElementsAttributes({ element: slide, screenshotsDir }))
);
return slideAttributes;
}
async function getSlides(page: Page) {
async function getSlidesAndSpeakerNotes(page: Page) {
const slides_wrapper = await getSlidesWrapper(page);
const speakerNotes = await getSpeakerNotes(slides_wrapper);
const slides = await slides_wrapper.$$(":scope > div > div");
return slides;
return { slides, speakerNotes };
}
async function getSlidesWrapper(page: Page): Promise<ElementHandle<Element>> {
@ -209,6 +210,12 @@ async function getSlidesWrapper(page: Page): Promise<ElementHandle<Element>> {
return slides_wrapper;
}
async function getSpeakerNotes(slides_wrapper: ElementHandle<Element>) {
return await slides_wrapper.evaluate((el) => {
return Array.from(el.querySelectorAll('[data-speaker-note]')).map((el) => el.getAttribute('data-speaker-note') || "");
});
}
async function getAllChildElementsAttributes({ element, rootRect = null, depth = 0, inheritedFont, inheritedBackground, inheritedBorderRadius, inheritedZIndex, inheritedOpacity, screenshotsDir }: GetAllChildElementsAttributesArgs): Promise<SlideAttributesResult> {
if (!rootRect) {
const rootAttributes = await getElementAttributes(element);
@ -261,10 +268,29 @@ async function getAllChildElementsAttributes({ element, rootRect = null, depth =
};
}
// Ignore elements with no size (width or height)
if (attributes.position === undefined || attributes.position.width === undefined || attributes.position.height === undefined || attributes.position.width === 0 || attributes.position.height === 0) {
continue;
}
// If element is paragraph and contains only inline formatting tags, don't go deeper
if (attributes.tagName === 'p') {
const innerElementTagNames = await childElementHandle.evaluate((el) => {
return Array.from(el.querySelectorAll('*')).map((e) => e.tagName.toLowerCase());
});
const allowedInlineTags = new Set(['strong', 'u', 'em', 'code', 's']);
const hasOnlyAllowedInlineTags = innerElementTagNames.every((tag) => allowedInlineTags.has(tag));
if (innerElementTagNames.length > 0 && hasOnlyAllowedInlineTags) {
attributes.innerText = await childElementHandle.evaluate((el) => {
return el.innerHTML;
});
allResults.push({ attributes, depth });
continue;
}
}
if (attributes.tagName === 'svg' || attributes.tagName === 'canvas' || attributes.tagName === 'table') {
attributes.should_screenshot = true;
attributes.element = childElementHandle;
@ -272,12 +298,11 @@ async function getAllChildElementsAttributes({ element, rootRect = null, depth =
allResults.push({ attributes, depth });
//? If the element is a canvas, or table, we don't need to go deeper
// If the element is a canvas, or table, we don't need to go deeper
if (attributes.should_screenshot && attributes.tagName !== 'svg') {
continue;
}
const childResults = await getAllChildElementsAttributes({
element: childElementHandle,
rootRect: rootRect,

View file

@ -0,0 +1,11 @@
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
export async function GET() {
const isDisabled = process.env.DISABLE_ANONYMOUS_TELEMETRY === 'true' || process.env.DISABLE_ANONYMOUS_TELEMETRY === 'True';
const telemetryEnabled = !isDisabled;
return NextResponse.json({ telemetryEnabled });
}

View file

@ -3,6 +3,7 @@ import localFont from "next/font/local";
import { Roboto, Instrument_Sans } from "next/font/google";
import "./globals.css";
import { Providers } from "./providers";
import MixpanelInitializer from "./MixpanelInitializer";
import { LayoutProvider } from "./(presentation-generator)/context/LayoutContext";
import { Toaster } from "@/components/ui/sonner";
const inter = localFont({
@ -85,9 +86,11 @@ export default function RootLayout({
className={`${inter.variable} ${roboto.variable} ${instrument_sans.variable} antialiased`}
>
<Providers>
<LayoutProvider>
{children}
</LayoutProvider>
<MixpanelInitializer>
<LayoutProvider>
{children}
</LayoutProvider>
</MixpanelInitializer>
</Providers>
<Toaster position="top-center" />
</body>

View file

@ -2,7 +2,7 @@
import { useState, useEffect, useMemo } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Loader2, Download, CheckCircle, X } from "lucide-react";
import { Loader2, Download, CheckCircle } from "lucide-react";
import { useSelector } from "react-redux";
import { RootState } from "@/store/store";
import { handleSaveLLMConfig } from "@/utils/storeHelpers";
@ -12,6 +12,8 @@ import {
pullOllamaModel,
} from "@/utils/providerUtils";
import { LLMConfig } from "@/types/llm_config";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
import { usePathname } from "next/navigation";
// Button state interface
interface ButtonState {
@ -25,6 +27,7 @@ interface ButtonState {
export default function Home() {
const router = useRouter();
const pathname = usePathname();
const config = useSelector((state: RootState) => state.userConfig);
const [llmConfig, setLlmConfig] = useState<LLMConfig>(config.llm_config);
@ -52,6 +55,7 @@ export default function Home() {
}, [downloadingModel?.downloaded, downloadingModel?.size]);
const handleSaveConfig = async () => {
trackEvent(MixpanelEvent.Home_SaveConfiguration_Button_Clicked, { pathname });
try {
setButtonState(prev => ({
...prev,
@ -59,11 +63,17 @@ export default function Home() {
isDisabled: true,
text: "Saving Configuration..."
}));
// API: save config
trackEvent(MixpanelEvent.Home_SaveConfiguration_API_Call);
await handleSaveLLMConfig(llmConfig);
if (llmConfig.LLM === "ollama" && llmConfig.OLLAMA_MODEL) {
// API: check model pulled
trackEvent(MixpanelEvent.Home_CheckOllamaModelPulled_API_Call);
const isPulled = await checkIfSelectedOllamaModelIsPulled(llmConfig.OLLAMA_MODEL);
if (!isPulled) {
setShowDownloadModal(true);
// API: download model
trackEvent(MixpanelEvent.Home_DownloadOllamaModel_API_Call);
await handleModelDownload();
}
}
@ -74,6 +84,8 @@ export default function Home() {
isDisabled: false,
text: "Save Configuration"
}));
// Track navigation from -> to
trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/upload" });
router.push("/upload");
} catch (error) {
toast.info(error instanceof Error ? error.message : "Failed to save configuration");

View file

@ -45,6 +45,7 @@
"lucide-react": "^0.447.0",
"marked": "^15.0.11",
"mermaid": "^11.9.0",
"mixpanel-browser": "^2.67.0",
"next": "^14.2.14",
"next-themes": "^0.4.6",
"prismjs": "^1.30.0",
@ -2827,6 +2828,16 @@
"integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==",
"license": "MIT"
},
"node_modules/@rrweb/types": {
"version": "2.0.0-alpha.18",
"resolved": "https://registry.npmjs.org/@rrweb/types/-/types-2.0.0-alpha.18.tgz",
"integrity": "sha512-iMH3amHthJZ9x3gGmBPmdfim7wLGygC2GciIkw2A6SO8giSn8PHYtRT8OKNH4V+k3SZ6RSnYHcTQxBA7pSWZ3Q=="
},
"node_modules/@rrweb/utils": {
"version": "2.0.0-alpha.18",
"resolved": "https://registry.npmjs.org/@rrweb/utils/-/utils-2.0.0-alpha.18.tgz",
"integrity": "sha512-qV8azQYo9RuwW4NGRtOiQfTBdHNL1B0Q//uRLMbCSjbaKqJYd88Js17Bdskj65a0Vgp2dwTLPIZ0gK47dfjfaA=="
},
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
@ -3334,6 +3345,11 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/css-font-loading-module": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz",
"integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q=="
},
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
@ -3724,6 +3740,11 @@
"@types/node": "*"
}
},
"node_modules/@xstate/fsm": {
"version": "1.6.5",
"resolved": "https://registry.npmjs.org/@xstate/fsm/-/fsm-1.6.5.tgz",
"integrity": "sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw=="
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@ -7110,6 +7131,14 @@
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT"
},
"node_modules/mixpanel-browser": {
"version": "2.67.0",
"resolved": "https://registry.npmjs.org/mixpanel-browser/-/mixpanel-browser-2.67.0.tgz",
"integrity": "sha512-LudY4eRIkvjEpAlIAg10i2T2mbtiKZ4XlMGbTyF1kcAhEqMa9JhEEdEcjxYPwiKhuMVSBM3RVkNCZaNqcnE4ww==",
"dependencies": {
"rrweb": "2.0.0-alpha.18"
}
},
"node_modules/mlly": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.4.tgz",
@ -8417,6 +8446,64 @@
"points-on-path": "^0.2.1"
}
},
"node_modules/rrdom": {
"version": "2.0.0-alpha.18",
"resolved": "https://registry.npmjs.org/rrdom/-/rrdom-2.0.0-alpha.18.tgz",
"integrity": "sha512-fSFzFFxbqAViITyYVA4Z0o5G6p1nEqEr/N8vdgSKie9Rn0FJxDSNJgjV0yiCIzcDs0QR+hpvgFhpbdZ6JIr5Nw==",
"dependencies": {
"rrweb-snapshot": "^2.0.0-alpha.18"
}
},
"node_modules/rrweb": {
"version": "2.0.0-alpha.18",
"resolved": "https://registry.npmjs.org/rrweb/-/rrweb-2.0.0-alpha.18.tgz",
"integrity": "sha512-1mjZcB+LVoGSx1+i9E2ZdAP90fS3MghYVix2wvGlZvrgRuLCbTCCOZMztFCkKpgp7/EeCdYM4nIHJkKX5J1Nmg==",
"dependencies": {
"@rrweb/types": "^2.0.0-alpha.18",
"@rrweb/utils": "^2.0.0-alpha.18",
"@types/css-font-loading-module": "0.0.7",
"@xstate/fsm": "^1.4.0",
"base64-arraybuffer": "^1.0.1",
"mitt": "^3.0.0",
"rrdom": "^2.0.0-alpha.18",
"rrweb-snapshot": "^2.0.0-alpha.18"
}
},
"node_modules/rrweb-snapshot": {
"version": "2.0.0-alpha.18",
"resolved": "https://registry.npmjs.org/rrweb-snapshot/-/rrweb-snapshot-2.0.0-alpha.18.tgz",
"integrity": "sha512-hBHZL/NfgQX6wO1D9mpwqFu1NJPpim+moIcKhFEjVTZVRUfCln+LOugRc4teVTCISYHN8Cw5e2iNTWCSm+SkoA==",
"dependencies": {
"postcss": "^8.4.38"
}
},
"node_modules/rrweb-snapshot/node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",

View file

@ -47,6 +47,7 @@
"lucide-react": "^0.447.0",
"marked": "^15.0.11",
"mermaid": "^11.9.0",
"mixpanel-browser": "^2.67.0",
"next": "^14.2.14",
"next-themes": "^0.4.6",
"prismjs": "^1.30.0",

View file

@ -78,4 +78,5 @@ export interface ElementAttributes {
export interface SlideAttributesResult {
elements: ElementAttributes[];
backgroundColor?: string;
speakerNote?: string;
}

View file

@ -327,6 +327,7 @@ export interface PptxConnectorModel extends PptxShapeModel {
export interface PptxSlideModel {
background?: PptxFillModel;
shapes: (PptxTextBoxModel | PptxAutoShapeBoxModel | PptxConnectorModel | PptxPictureBoxModel)[];
note?: string;
}
export interface PptxPresentationModel {

View file

@ -0,0 +1,160 @@
'use client';
import mixpanel from 'mixpanel-browser';
const MIXPANEL_TOKEN = 'd726e8bea8ec147f4c7720060cb2e6d1';
export enum MixpanelEvent {
PageView = 'Page View',
Navigation = 'Navigation',
Home_SaveConfiguration_Button_Clicked = 'Home Save Configuration Button Clicked',
Home_SaveConfiguration_API_Call = 'Home Save Configuration API Call',
Home_CheckOllamaModelPulled_API_Call = 'Home Check Ollama Model Pulled API Call',
Home_DownloadOllamaModel_API_Call = 'Home Download Ollama Model API Call',
Outline_Generate_Presentation_Button_Clicked = 'Outline Generate Presentation Button Clicked',
Outline_Select_Template_Button_Clicked = 'Outline Select Template Button Clicked',
Outline_Add_Slide_Button_Clicked = 'Outline Add Slide Button Clicked',
Presentation_Prepare_API_Call = 'Presentation Prepare API Call',
Presentation_Stream_API_Call = 'Presentation Stream API Call',
Group_Layout_Selected_Clicked = 'Group Layout Selected Clicked',
Header_Export_PDF_Button_Clicked = 'Header Export PDF Button Clicked',
Header_Export_PPTX_Button_Clicked = 'Header Export PPTX Button Clicked',
Header_UpdatePresentationContent_API_Call = 'Header Update Presentation Content API Call',
Header_ExportAsPDF_API_Call = 'Header Export As PDF API Call',
Header_GetPptxModel_API_Call = 'Header Get PPTX Model API Call',
Header_ExportAsPPTX_API_Call = 'Header Export As PPTX API Call',
Slide_Add_New_Slide_Button_Clicked = 'Slide Add New Slide Button Clicked',
Slide_Delete_Slide_Button_Clicked = 'Slide Delete Slide Button Clicked',
Slide_Update_From_Prompt_Button_Clicked = 'Slide Update From Prompt Button Clicked',
Slide_Edit_API_Call = 'Slide Edit API Call',
Slide_Delete_API_Call = 'Slide Delete API Call',
TemplatePreview_Back_Button_Clicked = 'Template Preview Back Button Clicked',
TemplatePreview_All_Groups_Button_Clicked = 'Template Preview All Groups Button Clicked',
TemplatePreview_Delete_Templates_Button_Clicked = 'Template Preview Delete Templates Button Clicked',
TemplatePreview_Delete_Templates_API_Call = 'Template Preview Delete Templates API Call',
TemplatePreview_Open_Editor_Button_Clicked = 'Template Preview Open Editor Button Clicked',
CustomTemplate_Save_Templates_API_Call = 'Custom Template Save Templates API Call',
PdfMaker_Retry_Button_Clicked = 'PDF Maker Retry Button Clicked',
Upload_Upload_Documents_API_Call = 'Upload Upload Documents API Call',
Upload_Decompose_Documents_API_Call = 'Upload Decompose Documents API Call',
Upload_Create_Presentation_API_Call = 'Upload Create Presentation API Call',
DocumentsPreview_Create_Presentation_API_Call = 'Documents Preview Create Presentation API Call',
DocumentsPreview_Next_Button_Clicked = 'Documents Preview Next Button Clicked',
Settings_SaveConfiguration_Button_Clicked = 'Settings Save Configuration Button Clicked',
Settings_SaveConfiguration_API_Call = 'Settings Save Configuration API Call',
Settings_CheckOllamaModelPulled_API_Call = 'Settings Check Ollama Model Pulled API Call',
Settings_DownloadOllamaModel_API_Call = 'Settings Download Ollama Model API Call',
PresentationPage_Refresh_Page_Button_Clicked = 'Presentation Page Refresh Page Button Clicked',
PresentationMode_Fullscreen_Toggle_Clicked = 'Presentation Mode Fullscreen Toggle Clicked',
PresentationMode_Exit_Clicked = 'Presentation Mode Exit Clicked',
ImageEditor_GetPreviousGeneratedImages_API_Call = 'Image Editor Get Previous Generated Images API Call',
ImageEditor_GenerateImage_API_Call = 'Image Editor Generate Image API Call',
ImageEditor_UploadImage_API_Call = 'Image Editor Upload Image API Call',
}
export type MixpanelProps = Record<string, unknown>;
declare global {
interface Window {
__mixpanel_initialized?: boolean;
__mixpanel_telemetry_enabled?: boolean;
}
}
function canUseMixpanel(): boolean {
return typeof window !== 'undefined' && Boolean(MIXPANEL_TOKEN);
}
let trackingCheckPromise: Promise<boolean> | null = null;
async function ensureTelemetryStatus(): Promise<boolean> {
if (typeof window === 'undefined') return false;
if (typeof window.__mixpanel_telemetry_enabled === 'boolean') {
return window.__mixpanel_telemetry_enabled;
}
if (!trackingCheckPromise) {
trackingCheckPromise = fetch('/api/telemetry-status')
.then(async (res) => {
try {
const data = await res.json();
const enabled = Boolean(data?.telemetryEnabled);
window.__mixpanel_telemetry_enabled = enabled;
return enabled;
} catch {
// If the API response is malformed, default to enabling tracking
window.__mixpanel_telemetry_enabled = true;
return true;
}
})
.catch(() => {
// If the API call fails, default to enabling tracking
window.__mixpanel_telemetry_enabled = true;
return true;
});
}
return trackingCheckPromise;
}
export function initMixpanel(): void {
if (!canUseMixpanel()) return;
if (window.__mixpanel_initialized) return;
// Ensure telemetry is allowed before initializing
void ensureTelemetryStatus().then((enabled) => {
if (!enabled) return;
if (window.__mixpanel_initialized) return;
mixpanel.init(MIXPANEL_TOKEN as string, { track_pageview: false });
mixpanel.identify(mixpanel.get_distinct_id());
window.__mixpanel_initialized = true;
});
}
export function track(eventName: string, props?: Record<string, unknown>): void {
if (!canUseMixpanel()) return;
if (typeof window !== 'undefined' && window.__mixpanel_telemetry_enabled === false) {
return;
}
if (!window.__mixpanel_initialized) {
initMixpanel();
return;
}
mixpanel.track(eventName, props);
}
export function trackEvent(event: MixpanelEvent, props?: MixpanelProps): void {
track(event, props);
}
export function getDistinctId(): string | undefined {
if (!canUseMixpanel()) return undefined;
if (typeof window !== 'undefined' && window.__mixpanel_telemetry_enabled === false) {
return undefined;
}
if (!window.__mixpanel_initialized) {
initMixpanel();
return undefined;
}
if (!window.__mixpanel_initialized) return undefined;
return mixpanel.get_distinct_id();
}
export function identifyAnonymous(): void {
if (!canUseMixpanel()) return;
if (typeof window !== 'undefined' && window.__mixpanel_telemetry_enabled === false) {
return;
}
if (!window.__mixpanel_initialized) {
initMixpanel();
return;
}
mixpanel.identify(mixpanel.get_distinct_id());
}
export default {
initMixpanel,
track,
trackEvent,
getDistinctId,
identifyAnonymous,
};

View file

@ -61,7 +61,8 @@ export function convertElementAttributesToPptxSlides(
}).filter(Boolean);
const slide: PptxSlideModel = {
shapes: shapes as (PptxTextBoxModel | PptxAutoShapeBoxModel | PptxConnectorModel | PptxPictureBoxModel)[]
shapes: shapes as (PptxTextBoxModel | PptxAutoShapeBoxModel | PptxConnectorModel | PptxPictureBoxModel)[],
note: slideAttributes.speakerNote
};
if (slideAttributes.backgroundColor) {