diff --git a/README.md b/README.md index c1856961..b82ae3c9 100644 --- a/README.md +++ b/README.md @@ -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") | diff --git a/docker-compose.yml b/docker-compose.yml index 85ca9210..39a24c97 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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} diff --git a/servers/fastapi/api/v1/ppt/endpoints/pptx_slides.py b/servers/fastapi/api/v1/ppt/endpoints/pptx_slides.py index 5044cd52..9b922f3c 100644 --- a/servers/fastapi/api/v1/ppt/endpoints/pptx_slides.py +++ b/servers/fastapi/api/v1/ppt/endpoints/pptx_slides.py @@ -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=[] ) diff --git a/servers/fastapi/api/v1/ppt/endpoints/presentation.py b/servers/fastapi/api/v1/ppt/endpoints/presentation.py index 52920367..4452553b 100644 --- a/servers/fastapi/api/v1/ppt/endpoints/presentation.py +++ b/servers/fastapi/api/v1/ppt/endpoints/presentation.py @@ -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) diff --git a/servers/fastapi/api/v1/ppt/endpoints/slide.py b/servers/fastapi/api/v1/ppt/endpoints/slide.py index a0c81107..e1ec9e6b 100644 --- a/servers/fastapi/api/v1/ppt/endpoints/slide.py +++ b/servers/fastapi/api/v1/ppt/endpoints/slide.py @@ -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() diff --git a/servers/fastapi/models/pptx_models.py b/servers/fastapi/models/pptx_models.py index ee1e4cd1..80da5cd9 100644 --- a/servers/fastapi/models/pptx_models.py +++ b/servers/fastapi/models/pptx_models.py @@ -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 diff --git a/servers/fastapi/models/sql/slide.py b/servers/fastapi/models/sql/slide.py index 7c0cb7e3..5d859d82 100644 --- a/servers/fastapi/models/sql/slide.py +++ b/servers/fastapi/models/sql/slide.py @@ -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, ) diff --git a/servers/fastapi/services/html_to_text_runs_service.py b/servers/fastapi/services/html_to_text_runs_service.py new file mode 100644 index 00000000..25a441a7 --- /dev/null +++ b/servers/fastapi/services/html_to_text_runs_service.py @@ -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", "
") + + parser = InlineHTMLToRunsParser(base_font if base_font else PptxFontModel()) + parser.feed(normalized_text) + return parser.text_runs + + diff --git a/servers/fastapi/services/pptx_presentation_creator.py b/servers/fastapi/services/pptx_presentation_creator.py index 44fd08ed..6563bd89 100644 --- a/servers/fastapi/services/pptx_presentation_creator.py +++ b/servers/fastapi/services/pptx_presentation_creator.py @@ -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) diff --git a/servers/fastapi/utils/llm_calls/edit_slide.py b/servers/fastapi/utils/llm_calls/edit_slide.py index 30599d08..5d91a607 100644 --- a/servers/fastapi/utils/llm_calls/edit_slide.py +++ b/servers/fastapi/utils/llm_calls/edit_slide.py @@ -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( diff --git a/servers/fastapi/utils/llm_calls/generate_slide_content.py b/servers/fastapi/utils/llm_calls/generate_slide_content.py index be19b168..e8f695a0 100644 --- a/servers/fastapi/utils/llm_calls/generate_slide_content.py +++ b/servers/fastapi/utils/llm_calls/generate_slide_content.py @@ -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, diff --git a/servers/fastapi/utils/schema_utils.py b/servers/fastapi/utils/schema_utils.py index 3c82ad0f..92aafd97 100644 --- a/servers/fastapi/utils/schema_utils.py +++ b/servers/fastapi/utils/schema_utils.py @@ -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, diff --git a/servers/nextjs/app/(presentation-generator)/components/HeaderNab.tsx b/servers/nextjs/app/(presentation-generator)/components/HeaderNab.tsx index d960ad8d..cb0f0338 100644 --- a/servers/nextjs/app/(presentation-generator)/components/HeaderNab.tsx +++ b/servers/nextjs/app/(presentation-generator)/components/HeaderNab.tsx @@ -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 (
@@ -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" })} > @@ -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" })} > diff --git a/servers/nextjs/app/(presentation-generator)/components/ImageEditor.tsx b/servers/nextjs/app/(presentation-generator)/components/ImageEditor.tsx index 29e67cc6..fb1c8337 100644 --- a/servers/nextjs/app/(presentation-generator)/components/ImageEditor.tsx +++ b/servers/nextjs/app/(presentation-generator)/components/ImageEditor.tsx @@ -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, diff --git a/servers/nextjs/app/(presentation-generator)/components/NewSlide.tsx b/servers/nextjs/app/(presentation-generator)/components/NewSlide.tsx index d852d803..05a6b638 100644 --- a/servers/nextjs/app/(presentation-generator)/components/NewSlide.tsx +++ b/servers/nextjs/app/(presentation-generator)/components/NewSlide.tsx @@ -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'; diff --git a/servers/nextjs/app/(presentation-generator)/custom-template/hooks/useSlideEdit.ts b/servers/nextjs/app/(presentation-generator)/custom-template/hooks/useSlideEdit.ts index 5194c150..7ab1d4cb 100644 --- a/servers/nextjs/app/(presentation-generator)/custom-template/hooks/useSlideEdit.ts +++ b/servers/nextjs/app/(presentation-generator)/custom-template/hooks/useSlideEdit.ts @@ -14,20 +14,7 @@ export const useSlideEdit = ( const [slideHtml, setSlideHtml] = useState(""); const slideContentRef = useRef(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(() => { diff --git a/servers/nextjs/app/(presentation-generator)/custom-template/hooks/useSlideProcessing.ts b/servers/nextjs/app/(presentation-generator)/custom-template/hooks/useSlideProcessing.ts index 544c4804..52e67a7c 100644 --- a/servers/nextjs/app/(presentation-generator)/custom-template/hooks/useSlideProcessing.ts +++ b/servers/nextjs/app/(presentation-generator)/custom-template/hooks/useSlideProcessing.ts @@ -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); diff --git a/servers/nextjs/app/(presentation-generator)/custom-template/page.tsx b/servers/nextjs/app/(presentation-generator)/custom-template/page.tsx index 338ebd4d..3c23d26f 100644 --- a/servers/nextjs/app/(presentation-generator)/custom-template/page.tsx +++ b/servers/nextjs/app/(presentation-generator)/custom-template/page.tsx @@ -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 => { + 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) { diff --git a/servers/nextjs/app/(presentation-generator)/dashboard/components/Header.tsx b/servers/nextjs/app/(presentation-generator)/dashboard/components/Header.tsx index 3708a494..4636347c 100644 --- a/servers/nextjs/app/(presentation-generator)/dashboard/components/Header.tsx +++ b/servers/nextjs/app/(presentation-generator)/dashboard/components/Header.tsx @@ -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 = () => {
{pathname !== "/upload" && } - + trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/dashboard" })}> Presentation logo { 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 = () => { 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" > diff --git a/servers/nextjs/app/(presentation-generator)/documents-preview/components/DocumentPreviewPage.tsx b/servers/nextjs/app/(presentation-generator)/documents-preview/components/DocumentPreviewPage.tsx index 718049fb..8ed2f14a 100644 --- a/servers/nextjs/app/(presentation-generator)/documents-preview/components/DocumentPreviewPage.tsx +++ b/servers/nextjs/app/(presentation-generator)/documents-preview/components/DocumentPreviewPage.tsx @@ -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(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); diff --git a/servers/nextjs/app/(presentation-generator)/outline/components/GenerateButton.tsx b/servers/nextjs/app/(presentation-generator)/outline/components/GenerateButton.tsx index 29359bb5..fd6a8ae2 100644 --- a/servers/nextjs/app/(presentation-generator)/outline/components/GenerateButton.tsx +++ b/servers/nextjs/app/(presentation-generator)/outline/components/GenerateButton.tsx @@ -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 = ({ selectedLayoutGroup, onSubmit }) => { + const pathname = usePathname(); + const isDisabled = loadingState.isLoading || streamState.isLoading || @@ -30,7 +34,16 @@ const GenerateButton: React.FC = ({ return ( @@ -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 ? (
{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) => ( -
+ // [data-speaker-note] is used to extract the speaker note from the slide for export to pptx +
{renderSlideContent(slide, true)}
))} diff --git a/servers/nextjs/app/(presentation-generator)/presentation/components/Header.tsx b/servers/nextjs/app/(presentation-generator)/presentation/components/Header.tsx index eef0e152..54ba9ae4 100644 --- a/servers/nextjs/app/(presentation-generator)/presentation/components/Header.tsx +++ b/servers/nextjs/app/(presentation-generator)/presentation/components/Header.tsx @@ -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 }) => (
+
); diff --git a/servers/nextjs/app/(presentation-generator)/presentation/components/SlideContent.tsx b/servers/nextjs/app/(presentation-generator)/presentation/components/SlideContent.tsx index 1953926e..342e7be1 100644 --- a/servers/nextjs/app/(presentation-generator)/presentation/components/SlideContent.tsx +++ b/servers/nextjs/app/(presentation-generator)/presentation/components/SlideContent.tsx @@ -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) => { {!isStreaming && !loading && (
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" > @@ -169,7 +177,10 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => { {!isStreaming && !loading && (
{ + 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" > @@ -219,9 +230,11 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => { - - {isCustom && } -
- -
-

- {layoutGroup[0].groupName} Layouts -

-

- {layoutGroup.length} layout{layoutGroup.length !== 1 ? "s" : ""} •{" "} - {layoutGroup[0].groupName} -

-
- -
- - - -
- {/* Edit Controls (no HTML editor) */} - {isCustom && ( - { - setIsUpdating(true); - setTimeout(() => { - setIsUpdating(false); - setIsEditMode(false); - setSelectedIndex(null); - }, 300); - }} - onCancel={() => { - setIsEditMode(false); - setSelectedIndex(null); - handleClearCanvas(); - }} - onStrokeWidthChange={handleStrokeWidthChange} - onStrokeColorChange={handleStrokeColorChange} - onEraserModeChange={handleEraserModeChange} - onClearCanvas={handleClearCanvas} - /> - )} -
- {layoutGroup.map((layout: any, index: number) => { - const { - component: LayoutComponent, - sampleData, - name, - fileName, - } = layout; - - const isSelected = isCustom && isEditMode && selectedIndex === index; - - return ( - - {/* Layout Header */} -
-
-
-

- {name} -

-
- - {fileName} - - - {layoutGroup[0].groupName} - -
-
-
- {isCustom && ( - - )} -
-
-
- - {/* Layout Content */} -
-
- - {isSelected && ( - e.preventDefault()} - /> - )} -
-
-
- ); - })} -
-
- - {/* Footer */} -
-
-
-

- {layoutGroup[0].groupName} • {layoutGroup.length} components -

-
-
-
-
- ); - }; - - export default GroupLayoutPreview; - diff --git a/servers/nextjs/app/(presentation-generator)/template-preview/[slug]/page.tsx b/servers/nextjs/app/(presentation-generator)/template-preview/[slug]/page.tsx index 9c5d2f5d..96b519a8 100644 --- a/servers/nextjs/app/(presentation-generator)/template-preview/[slug]/page.tsx +++ b/servers/nextjs/app/(presentation-generator)/template-preview/[slug]/page.tsx @@ -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>({}); 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 = () => { {slug.includes('custom-') && }
@@ -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"} > diff --git a/servers/nextjs/app/(presentation-generator)/template-preview/page.tsx b/servers/nextjs/app/(presentation-generator)/template-preview/page.tsx index 34d495e6..b51955a8 100644 --- a/servers/nextjs/app/(presentation-generator)/template-preview/page.tsx +++ b/servers/nextjs/app/(presentation-generator)/template-preview/page.tsx @@ -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>({}); @@ -114,7 +116,10 @@ const LayoutPreview = () => { router.push(`/template-preview/${group.groupName}`)} + onClick={() => { + trackEvent(MixpanelEvent.Navigation, { from: pathname, to: `/template-preview/${group.groupName}` }); + router.push(`/template-preview/${group.groupName}`) + }} >
@@ -166,7 +171,10 @@ const LayoutPreview = () => { router.push(`/template-preview/${group.groupName}`)} + onClick={() => { + trackEvent(MixpanelEvent.Navigation, { from: pathname, to: `/template-preview/${group.groupName}` }); + router.push(`/template-preview/${group.groupName}`) + }} >
@@ -196,7 +204,10 @@ const LayoutPreview = () => { ) : ( router.push(`/custom-template`)} + onClick={() => { + trackEvent(MixpanelEvent.Navigation, { from: pathname, to: `/custom-template` }); + router.push(`/custom-template`) + }} >
diff --git a/servers/nextjs/app/(presentation-generator)/upload/components/UploadPage.tsx b/servers/nextjs/app/(presentation-generator)/upload/components/UploadPage.tsx index 8b14b69e..e47f29cb 100644 --- a/servers/nextjs/app/(presentation-generator)/upload/components/UploadPage.tsx +++ b/servers/nextjs/app/(presentation-generator)/upload/components/UploadPage.tsx @@ -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[] = []; 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"); }; diff --git a/servers/nextjs/app/MixpanelInitializer.tsx b/servers/nextjs/app/MixpanelInitializer.tsx new file mode 100644 index 00000000..caccf109 --- /dev/null +++ b/servers/nextjs/app/MixpanelInitializer.tsx @@ -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; + + diff --git a/servers/nextjs/app/api/presentation_to_pptx_model/route.ts b/servers/nextjs/app/api/presentation_to_pptx_model/route.ts index f6dcd04d..3a312a18 100644 --- a/servers/nextjs/app/api/presentation_to_pptx_model/route.ts +++ b/servers/nextjs/app/api/presentation_to_pptx_model/route.ts @@ -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[], 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> { @@ -209,6 +210,12 @@ async function getSlidesWrapper(page: Page): Promise> { return slides_wrapper; } +async function getSpeakerNotes(slides_wrapper: ElementHandle) { + 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 { 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, diff --git a/servers/nextjs/app/api/telemetry-status/route.ts b/servers/nextjs/app/api/telemetry-status/route.ts new file mode 100644 index 00000000..f9f1f880 --- /dev/null +++ b/servers/nextjs/app/api/telemetry-status/route.ts @@ -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 }); +} + + diff --git a/servers/nextjs/app/layout.tsx b/servers/nextjs/app/layout.tsx index 4b739142..e575b51f 100644 --- a/servers/nextjs/app/layout.tsx +++ b/servers/nextjs/app/layout.tsx @@ -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`} > - - {children} - + + + {children} + + diff --git a/servers/nextjs/components/Home.tsx b/servers/nextjs/components/Home.tsx index a62bd349..27f34409 100644 --- a/servers/nextjs/components/Home.tsx +++ b/servers/nextjs/components/Home.tsx @@ -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(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"); diff --git a/servers/nextjs/package-lock.json b/servers/nextjs/package-lock.json index 94b61767..54604588 100644 --- a/servers/nextjs/package-lock.json +++ b/servers/nextjs/package-lock.json @@ -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", diff --git a/servers/nextjs/package.json b/servers/nextjs/package.json index 8fdd24c3..f8a7d656 100644 --- a/servers/nextjs/package.json +++ b/servers/nextjs/package.json @@ -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", diff --git a/servers/nextjs/types/element_attibutes.ts b/servers/nextjs/types/element_attibutes.ts index c832a6a2..00d81b84 100644 --- a/servers/nextjs/types/element_attibutes.ts +++ b/servers/nextjs/types/element_attibutes.ts @@ -78,4 +78,5 @@ export interface ElementAttributes { export interface SlideAttributesResult { elements: ElementAttributes[]; backgroundColor?: string; + speakerNote?: string; } \ No newline at end of file diff --git a/servers/nextjs/types/pptx_models.ts b/servers/nextjs/types/pptx_models.ts index dc35cdf2..8cf2e7c0 100644 --- a/servers/nextjs/types/pptx_models.ts +++ b/servers/nextjs/types/pptx_models.ts @@ -327,6 +327,7 @@ export interface PptxConnectorModel extends PptxShapeModel { export interface PptxSlideModel { background?: PptxFillModel; shapes: (PptxTextBoxModel | PptxAutoShapeBoxModel | PptxConnectorModel | PptxPictureBoxModel)[]; + note?: string; } export interface PptxPresentationModel { diff --git a/servers/nextjs/utils/mixpanel.ts b/servers/nextjs/utils/mixpanel.ts new file mode 100644 index 00000000..7099e3e4 --- /dev/null +++ b/servers/nextjs/utils/mixpanel.ts @@ -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; + +declare global { + interface Window { + __mixpanel_initialized?: boolean; + __mixpanel_telemetry_enabled?: boolean; + } +} + +function canUseMixpanel(): boolean { + return typeof window !== 'undefined' && Boolean(MIXPANEL_TOKEN); +} + +let trackingCheckPromise: Promise | null = null; + +async function ensureTelemetryStatus(): Promise { + 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): 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, +}; + + diff --git a/servers/nextjs/utils/pptx_models_utils.ts b/servers/nextjs/utils/pptx_models_utils.ts index e314f647..7cae718c 100644 --- a/servers/nextjs/utils/pptx_models_utils.ts +++ b/servers/nextjs/utils/pptx_models_utils.ts @@ -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) {