From 3e915cbd5b445874fa51a962004e8322163992ad Mon Sep 17 00:00:00 2001 From: sauravniraula Date: Mon, 11 Aug 2025 21:21:20 +0545 Subject: [PATCH 01/13] docs(readme) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c1856961..cbe43bae 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ 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 * ✅ **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. From 70a1c1d65481624aa5986522f09a388b90d22eda Mon Sep 17 00:00:00 2001 From: sauravniraula Date: Mon, 11 Aug 2025 21:48:42 +0545 Subject: [PATCH 02/13] docs(readme) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index cbe43bae..eca3581b 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ Presenton gives you complete control over your AI presentation workflow. Choose your models, customize your experience, and keep your data private. * ✅ **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. From 2d04bd2f0c6ae72679b9bee71235548becea69ec Mon Sep 17 00:00:00 2001 From: Saurav Niraula Date: Mon, 11 Aug 2025 22:00:03 +0545 Subject: [PATCH 03/13] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index eca3581b..9cd5d2c7 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,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") | From 9c0f49e75cc71bbc4aa902a7d0f2636c7d1ee66e Mon Sep 17 00:00:00 2001 From: shiva raj badu Date: Mon, 11 Aug 2025 23:18:18 +0545 Subject: [PATCH 04/13] fix(Nextjs): Styling in Custom Template & pdf Export fonts issue --- .../components/NewSlide.tsx | 1 - .../custom-template/hooks/useSlideEdit.ts | 15 +- .../custom-template/page.tsx | 14 +- .../pdf-maker/PdfMakerPage.tsx | 12 + .../template-preview/[slug]/backup.tsx | 400 ------------------ .../template-preview/[slug]/page.tsx | 25 +- 6 files changed, 31 insertions(+), 436 deletions(-) delete mode 100644 servers/nextjs/app/(presentation-generator)/template-preview/[slug]/backup.tsx 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/page.tsx b/servers/nextjs/app/(presentation-generator)/custom-template/page.tsx index 338ebd4d..6becf5ac 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"; @@ -67,6 +67,18 @@ 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)/pdf-maker/PdfMakerPage.tsx b/servers/nextjs/app/(presentation-generator)/pdf-maker/PdfMakerPage.tsx index 0f35df8b..922c9f87 100644 --- a/servers/nextjs/app/(presentation-generator)/pdf-maker/PdfMakerPage.tsx +++ b/servers/nextjs/app/(presentation-generator)/pdf-maker/PdfMakerPage.tsx @@ -9,17 +9,28 @@ 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 [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 +62,7 @@ const PresentationPage = ({ presentation_id }: { presentation_id: string }) => { setContentLoading(false); } }; + // Regular view return (
diff --git a/servers/nextjs/app/(presentation-generator)/template-preview/[slug]/backup.tsx b/servers/nextjs/app/(presentation-generator)/template-preview/[slug]/backup.tsx deleted file mode 100644 index db76f920..00000000 --- a/servers/nextjs/app/(presentation-generator)/template-preview/[slug]/backup.tsx +++ /dev/null @@ -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(null); - - const { getFullDataByGroup, loading,refetch } = useLayout(); - const layoutGroup = getFullDataByGroup(slug); - const [isEditMode, setIsEditMode] = useState(false); - const [selectedIndex, setSelectedIndex] = useState(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 ; - } - - // Handle empty state - if (!layoutGroup || layoutGroup.length === 0) { - return ; - } - 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, - 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 ( -
- {/* Header */} -
-
- {/* Navigation */} -
- - - {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..11f9af4c 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 LoadingStates from "../components/LoadingStates"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; @@ -14,6 +13,7 @@ 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"; const GroupLayoutPreview = () => { const params = useParams(); @@ -35,22 +35,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 +59,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 +87,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 +119,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); }; From 362af45189907184685094f53d18e30476ed2967 Mon Sep 17 00:00:00 2001 From: sauravniraula Date: Tue, 12 Aug 2025 13:10:29 +0545 Subject: [PATCH 05/13] fix(fastapi): solves issue on PPTX export where markdown content was not correctly parsed --- servers/fastapi/models/pptx_models.py | 2 + .../services/html_to_text_runs_service.py | 65 +++++++++++++ .../services/pptx_presentation_creator.py | 93 ++++--------------- .../api/presentation_to_pptx_model/route.ts | 22 ++++- 4 files changed, 107 insertions(+), 75 deletions(-) create mode 100644 servers/fastapi/services/html_to_text_runs_service.py diff --git a/servers/fastapi/models/pptx_models.py b/servers/fastapi/models/pptx_models.py index ee1e4cd1..cf51d966 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): 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..6c778b0d 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 @@ -276,7 +279,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 +289,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 +460,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/nextjs/app/api/presentation_to_pptx_model/route.ts b/servers/nextjs/app/api/presentation_to_pptx_model/route.ts index f6dcd04d..64df9a6b 100644 --- a/servers/nextjs/app/api/presentation_to_pptx_model/route.ts +++ b/servers/nextjs/app/api/presentation_to_pptx_model/route.ts @@ -261,10 +261,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 +291,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, From 29841bdd06b33c9dd7bdda1a26cdd2fd96470e8c Mon Sep 17 00:00:00 2001 From: sauravniraula Date: Tue, 12 Aug 2025 16:18:57 +0545 Subject: [PATCH 06/13] feat(fastapi): adds logic to generate speaker notes and slide note support in export --- .../api/v1/ppt/endpoints/presentation.py | 1 + servers/fastapi/api/v1/ppt/endpoints/slide.py | 2 +- servers/fastapi/models/pptx_models.py | 1 + servers/fastapi/models/sql/slide.py | 2 + .../services/pptx_presentation_creator.py | 3 ++ servers/fastapi/utils/llm_calls/edit_slide.py | 19 ++++++++- .../utils/llm_calls/generate_slide_content.py | 16 ++++++- servers/fastapi/utils/schema_utils.py | 42 +++++++++++++++++++ 8 files changed, 82 insertions(+), 4 deletions(-) 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 cf51d966..80da5cd9 100644 --- a/servers/fastapi/models/pptx_models.py +++ b/servers/fastapi/models/pptx_models.py @@ -156,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/pptx_presentation_creator.py b/servers/fastapi/services/pptx_presentation_creator.py index 6c778b0d..6563bd89 100644 --- a/servers/fastapi/services/pptx_presentation_creator.py +++ b/servers/fastapi/services/pptx_presentation_creator.py @@ -147,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) 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, From 52781461265bad39a408bafde5a3a7d1430885f7 Mon Sep 17 00:00:00 2001 From: sauravniraula Date: Tue, 12 Aug 2025 16:57:57 +0545 Subject: [PATCH 07/13] feat(nextjs): adds speaker note attribute in HtmlElement and extracts it on export --- .../pdf-maker/PdfMakerPage.tsx | 2 +- .../api/presentation_to_pptx_model/route.ts | 21 ++++++++++++------- servers/nextjs/types/element_attibutes.ts | 1 + servers/nextjs/types/pptx_models.ts | 1 + servers/nextjs/utils/pptx_models_utils.ts | 3 ++- 5 files changed, 19 insertions(+), 9 deletions(-) diff --git a/servers/nextjs/app/(presentation-generator)/pdf-maker/PdfMakerPage.tsx b/servers/nextjs/app/(presentation-generator)/pdf-maker/PdfMakerPage.tsx index 922c9f87..d88bd670 100644 --- a/servers/nextjs/app/(presentation-generator)/pdf-maker/PdfMakerPage.tsx +++ b/servers/nextjs/app/(presentation-generator)/pdf-maker/PdfMakerPage.tsx @@ -115,7 +115,7 @@ const PresentationPage = ({ presentation_id }: { presentation_id: string }) => { presentationData.slides && presentationData.slides.length > 0 && presentationData.slides.map((slide: any, index: number) => ( -
+
{renderSlideContent(slide, true)}
))} 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 64df9a6b..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); 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/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) { From c2a82fd3961568861cbcefaec93f1f4188a00e4f Mon Sep 17 00:00:00 2001 From: Suraj Jha Date: Tue, 12 Aug 2025 20:14:57 +0545 Subject: [PATCH 08/13] Update README.md: ai template generation text --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 9cd5d2c7..9775e5f2 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) From 4b2a84b320cc971095321135b46752eb510989ef Mon Sep 17 00:00:00 2001 From: sauravniraula Date: Tue, 12 Aug 2025 20:15:17 +0545 Subject: [PATCH 09/13] feat(nextjs): adds basic anonymous tracking that tracks navigation, api calls (Just name of endpoint is tracked) and button clicks --- README.md | 5 +- docker-compose.yml | 4 + .../components/HeaderNab.tsx | 5 + .../components/ImageEditor.tsx | 4 + .../custom-template/page.tsx | 5 +- .../dashboard/components/Header.tsx | 5 +- .../components/DocumentPreviewPage.tsx | 6 +- .../outline/components/GenerateButton.tsx | 11 +- .../outline/components/GroupLayouts.tsx | 10 +- .../outline/components/OutlineContent.tsx | 17 +- .../pdf-maker/PdfMakerPage.tsx | 36 ++-- .../presentation/components/Header.tsx | 28 +++- .../components/PresentationPage.tsx | 6 +- .../presentation/components/SlideContent.tsx | 21 ++- .../settings/SettingPage.tsx | 11 +- .../template-preview/[slug]/page.tsx | 26 ++- .../template-preview/page.tsx | 19 ++- .../upload/components/UploadPage.tsx | 14 +- servers/nextjs/app/MixpanelInitializer.tsx | 30 ++++ .../nextjs/app/api/tracking-status/route.ts | 11 ++ servers/nextjs/app/layout.tsx | 9 +- servers/nextjs/components/Home.tsx | 15 ++ servers/nextjs/package-lock.json | 87 ++++++++++ servers/nextjs/package.json | 1 + servers/nextjs/utils/mixpanel.ts | 156 ++++++++++++++++++ 25 files changed, 497 insertions(+), 45 deletions(-) create mode 100644 servers/nextjs/app/MixpanelInitializer.tsx create mode 100644 servers/nextjs/app/api/tracking-status/route.ts create mode 100644 servers/nextjs/utils/mixpanel.ts diff --git a/README.md b/README.md index 9cd5d2c7..550d73c8 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,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 @@ -103,6 +102,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 tracking using the following environment variable: +- **DISABLE_ANONYMOUS_TRACKING=[true/false]**: Set this to **true** to disable anonymous usage tracking. + + > **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 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/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)/custom-template/page.tsx b/servers/nextjs/app/(presentation-generator)/custom-template/page.tsx index 6becf5ac..b77ded5d 100644 --- a/servers/nextjs/app/(presentation-generator)/custom-template/page.tsx +++ b/servers/nextjs/app/(presentation-generator)/custom-template/page.tsx @@ -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}`); 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..63b375d3 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, useSearchParams } from "next/navigation"; +import { trackEvent, MixpanelEvent } from "@/utils/mixpanel"; import { Button } from "@/components/ui/button"; import { LoadingState, LayoutGroup } from "../types/index"; @@ -15,6 +17,9 @@ const GenerateButton: React.FC = ({ selectedLayoutGroup, onSubmit }) => { + const pathname = usePathname(); + const searchParams = useSearchParams(); + const isDisabled = loadingState.isLoading || streamState.isLoading || @@ -30,7 +35,11 @@ const GenerateButton: React.FC = ({ return ( @@ -95,10 +102,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) => ( @@ -115,7 +122,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..7f2859af 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, useSearchParams } 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,8 @@ const Header = ({ const [open, setOpen] = useState(false); const [showLoader, setShowLoader] = useState(false); const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); const { presentationData, isStreaming } = useSelector( @@ -59,13 +62,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 +98,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 +144,22 @@ 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..e94f7ef2 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, useSearchParams } from "next/navigation"; +import { trackEvent, MixpanelEvent } from "@/utils/mixpanel"; import NewSlide from "../../components/NewSlide"; interface SlideContentProps { @@ -35,6 +37,8 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => { // Use the centralized group layouts hook const { renderSlideContent, loading } = useGroupLayouts(); + const pathname = usePathname(); + const searchParams = useSearchParams(); const handleSubmit = async () => { const element = document.getElementById( @@ -48,6 +52,7 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => { setIsUpdating(true); try { + trackEvent(MixpanelEvent.Slide_Edit_API_Call); const response = await PresentationGenerationApi.editSlide( slide.id, value @@ -149,7 +154,11 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => { {!isStreaming && !loading && (
setShowNewSlideSelection(true)} + onClick={() => { + const query = searchParams?.toString(); + trackEvent(MixpanelEvent.Slide_Add_New_Slide_Button_Clicked, { pathname, query }); + 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 +178,11 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => { {!isStreaming && !loading && (
{ + const query = searchParams?.toString(); + trackEvent(MixpanelEvent.Slide_Delete_Slide_Button_Clicked, { pathname, query }); + onDeleteSlide(); + }} className="absolute top-2 z-20 sm:top-4 right-2 sm:right-4 hidden md:block transition-transform" > @@ -222,6 +235,10 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => { 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={() => { + const query = searchParams?.toString(); + trackEvent(MixpanelEvent.Slide_Update_From_Prompt_Button_Clicked, { pathname, query }); + }} > {isUpdating ? "Updating..." : "Update"} diff --git a/servers/nextjs/app/(presentation-generator)/settings/SettingPage.tsx b/servers/nextjs/app/(presentation-generator)/settings/SettingPage.tsx index 980d2d05..83b6ec25 100644 --- a/servers/nextjs/app/(presentation-generator)/settings/SettingPage.tsx +++ b/servers/nextjs/app/(presentation-generator)/settings/SettingPage.tsx @@ -9,10 +9,11 @@ import { checkIfSelectedOllamaModelIsPulled, pullOllamaModel, } from "@/utils/providerUtils"; -import { useRouter } from "next/navigation"; +import { useRouter, usePathname, useSearchParams } 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,8 @@ interface ButtonState { const SettingsPage = () => { const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); const userConfigState = useSelector((state: RootState) => state.userConfig); const [llmConfig, setLlmConfig] = useState( userConfigState.llm_config @@ -61,6 +64,8 @@ const SettingsPage = () => { }, [downloadingModel?.downloaded, downloadingModel?.size]); const handleSaveConfig = async () => { + const query = searchParams?.toString(); + trackEvent(MixpanelEvent.Settings_SaveConfiguration_Button_Clicked, { pathname, query }); try { setButtonState(prev => ({ ...prev, @@ -68,13 +73,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 +93,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"); 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 11f9af4c..0b54db1e 100644 --- a/servers/nextjs/app/(presentation-generator)/template-preview/[slug]/page.tsx +++ b/servers/nextjs/app/(presentation-generator)/template-preview/[slug]/page.tsx @@ -1,6 +1,6 @@ "use client"; import React, { useEffect, useState } from "react"; -import { useParams, useRouter } from "next/navigation"; +import { useParams, useRouter, usePathname, useSearchParams } from "next/navigation"; import LoadingStates from "../components/LoadingStates"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; @@ -14,11 +14,14 @@ 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 searchParams = useSearchParams(); const { getFullDataByGroup, loading, refetch } = useLayout(); const layoutGroup = getFullDataByGroup(slug); @@ -177,7 +180,11 @@ const GroupLayoutPreview = () => { {slug.includes('custom-') && }
@@ -250,7 +264,11 @@ 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={() => { + const query = searchParams?.toString?.() as string | undefined; + trackEvent(MixpanelEvent.TemplatePreview_Open_Editor_Button_Clicked, { pathname, query }); + 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..f9689180 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, useSearchParams } 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,8 @@ interface LoadingState { const UploadPage = () => { const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); const dispatch = useDispatch(); // State management @@ -115,6 +118,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 +126,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 +135,7 @@ const UploadPage = () => { files: responses, })); dispatch(clearOutlines()) + trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/documents-preview" }); router.push("/documents-preview"); }; @@ -147,6 +151,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 +161,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..65b23fd6 --- /dev/null +++ b/servers/nextjs/app/MixpanelInitializer.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { useEffect } from 'react'; +import { usePathname, useSearchParams } from 'next/navigation'; +import { initMixpanel, trackEvent, MixpanelEvent } from '@/utils/mixpanel'; + +export function MixpanelInitializer({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const searchParams = useSearchParams(); + + // Initialize once + useEffect(() => { + initMixpanel(); + }, []); + + // Track page views on route changes + useEffect(() => { + if (!pathname) return; + const query = searchParams?.toString(); + const url = query ? `${pathname}?${query}` : pathname; + trackEvent(MixpanelEvent.PageView, { url }); + }, [pathname, searchParams]); + + + return <>{children}; +} + +export default MixpanelInitializer; + + diff --git a/servers/nextjs/app/api/tracking-status/route.ts b/servers/nextjs/app/api/tracking-status/route.ts new file mode 100644 index 00000000..3cc727ff --- /dev/null +++ b/servers/nextjs/app/api/tracking-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_TRACKING === 'true' || process.env.DISABLE_ANONYMOUS_TRACKING === 'True'; + const trackingEnabled = !isDisabled; + return NextResponse.json({ trackingEnabled }); +} + + 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..414e78cc 100644 --- a/servers/nextjs/components/Home.tsx +++ b/servers/nextjs/components/Home.tsx @@ -12,6 +12,8 @@ import { pullOllamaModel, } from "@/utils/providerUtils"; import { LLMConfig } from "@/types/llm_config"; +import { trackEvent, MixpanelEvent } from "@/utils/mixpanel"; +import { usePathname, useSearchParams } from "next/navigation"; // Button state interface interface ButtonState { @@ -25,6 +27,8 @@ interface ButtonState { export default function Home() { const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); const config = useSelector((state: RootState) => state.userConfig); const [llmConfig, setLlmConfig] = useState(config.llm_config); @@ -52,6 +56,9 @@ export default function Home() { }, [downloadingModel?.downloaded, downloadingModel?.size]); const handleSaveConfig = async () => { + // Track button click with pathname and searchParams + const query = searchParams?.toString(); + trackEvent(MixpanelEvent.Home_SaveConfiguration_Button_Clicked, { pathname, query }); try { setButtonState(prev => ({ ...prev, @@ -59,11 +66,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 +87,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/utils/mixpanel.ts b/servers/nextjs/utils/mixpanel.ts new file mode 100644 index 00000000..0261861f --- /dev/null +++ b/servers/nextjs/utils/mixpanel.ts @@ -0,0 +1,156 @@ +'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', + Generate_Presentation_Button_Clicked = 'Generate Presentation Button Clicked', + Outline_Add_Slide_Button_Clicked = 'Outline Add Slide Button Clicked', + 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', + 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_tracking_enabled?: boolean; + } +} + +function canUseMixpanel(): boolean { + return typeof window !== 'undefined' && Boolean(MIXPANEL_TOKEN); +} + +let trackingCheckPromise: Promise | null = null; + +async function ensureTrackingStatus(): Promise { + if (typeof window === 'undefined') return false; + if (typeof window.__mixpanel_tracking_enabled === 'boolean') { + return window.__mixpanel_tracking_enabled; + } + if (!trackingCheckPromise) { + trackingCheckPromise = fetch('/api/tracking-status') + .then(async (res) => { + try { + const data = await res.json(); + const enabled = Boolean(data?.trackingEnabled); + window.__mixpanel_tracking_enabled = enabled; + return enabled; + } catch { + // If the API response is malformed, default to enabling tracking + window.__mixpanel_tracking_enabled = true; + return true; + } + }) + .catch(() => { + // If the API call fails, default to enabling tracking + window.__mixpanel_tracking_enabled = true; + return true; + }); + } + return trackingCheckPromise; +} + +export function initMixpanel(): void { + if (!canUseMixpanel()) return; + if (window.__mixpanel_initialized) return; + // Ensure tracking is allowed before initializing + void ensureTrackingStatus().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_tracking_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_tracking_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_tracking_enabled === false) { + return; + } + if (!window.__mixpanel_initialized) { + initMixpanel(); + return; + } + mixpanel.identify(mixpanel.get_distinct_id()); +} + +export default { + initMixpanel, + track, + trackEvent, + getDistinctId, + identifyAnonymous, +}; + + From 88bbff499807bb9cb9bf420d0d315455d0289e24 Mon Sep 17 00:00:00 2001 From: sauravniraula Date: Wed, 13 Aug 2025 13:21:43 +0545 Subject: [PATCH 10/13] fix(nextjs): changes some event names and removes search query from button click event --- .../outline/components/GenerateButton.tsx | 12 +++++++---- .../outline/components/GroupLayouts.tsx | 6 ++---- .../outline/components/OutlineContent.tsx | 9 +++------ .../pdf-maker/PdfMakerPage.tsx | 5 ++--- .../presentation/components/Header.tsx | 9 +++------ .../components/PresentationPage.tsx | 5 ++--- .../presentation/components/SlideContent.tsx | 20 ++++++++----------- .../settings/SettingPage.tsx | 6 ++---- .../template-preview/[slug]/page.tsx | 15 +++++--------- .../upload/components/UploadPage.tsx | 3 +-- servers/nextjs/app/MixpanelInitializer.tsx | 14 ++++--------- servers/nextjs/components/Home.tsx | 9 +++------ servers/nextjs/utils/mixpanel.ts | 4 +++- 13 files changed, 46 insertions(+), 71 deletions(-) diff --git a/servers/nextjs/app/(presentation-generator)/outline/components/GenerateButton.tsx b/servers/nextjs/app/(presentation-generator)/outline/components/GenerateButton.tsx index 63b375d3..fd6a8ae2 100644 --- a/servers/nextjs/app/(presentation-generator)/outline/components/GenerateButton.tsx +++ b/servers/nextjs/app/(presentation-generator)/outline/components/GenerateButton.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { usePathname, useSearchParams } from "next/navigation"; +import { usePathname } from "next/navigation"; import { trackEvent, MixpanelEvent } from "@/utils/mixpanel"; import { Button } from "@/components/ui/button"; import { LoadingState, LayoutGroup } from "../types/index"; @@ -18,7 +18,6 @@ const GenerateButton: React.FC = ({ onSubmit }) => { const pathname = usePathname(); - const searchParams = useSearchParams(); const isDisabled = loadingState.isLoading || @@ -36,8 +35,13 @@ const GenerateButton: React.FC = ({ +
); diff --git a/servers/nextjs/app/(presentation-generator)/presentation/components/SlideContent.tsx b/servers/nextjs/app/(presentation-generator)/presentation/components/SlideContent.tsx index e94f7ef2..342e7be1 100644 --- a/servers/nextjs/app/(presentation-generator)/presentation/components/SlideContent.tsx +++ b/servers/nextjs/app/(presentation-generator)/presentation/components/SlideContent.tsx @@ -17,7 +17,7 @@ import { updateSlide, } from "@/store/slices/presentationGeneration"; import { useGroupLayouts } from "../../hooks/useGroupLayouts"; -import { usePathname, useSearchParams } from "next/navigation"; +import { usePathname } from "next/navigation"; import { trackEvent, MixpanelEvent } from "@/utils/mixpanel"; import NewSlide from "../../components/NewSlide"; @@ -38,7 +38,6 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => { // Use the centralized group layouts hook const { renderSlideContent, loading } = useGroupLayouts(); const pathname = usePathname(); - const searchParams = useSearchParams(); const handleSubmit = async () => { const element = document.getElementById( @@ -73,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); @@ -113,7 +113,7 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => { return; } if (slide.layout.includes("custom")) { - + const existingScript = document.querySelector( 'script[src*="tailwindcss.com"]' ); @@ -155,8 +155,7 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => { {!isStreaming && !loading && (
{ - const query = searchParams?.toString(); - trackEvent(MixpanelEvent.Slide_Add_New_Slide_Button_Clicked, { pathname, query }); + 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" @@ -179,8 +178,7 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => {
{ - const query = searchParams?.toString(); - trackEvent(MixpanelEvent.Slide_Delete_Slide_Button_Clicked, { pathname, query }); + 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" @@ -232,12 +230,10 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => { {slug.includes('custom-') && } @@ -265,8 +261,7 @@ const GroupLayoutPreview = () => { size="sm" className="flex items-center gap-2 bg-blue-50 border border-blue-400 text-blue-700" onClick={() => { - const query = searchParams?.toString?.() as string | undefined; - trackEvent(MixpanelEvent.TemplatePreview_Open_Editor_Button_Clicked, { pathname, query }); + trackEvent(MixpanelEvent.TemplatePreview_Open_Editor_Button_Clicked, { pathname }); openEditor(fileName); }} disabled={!layoutsMap[fileName]} diff --git a/servers/nextjs/app/(presentation-generator)/upload/components/UploadPage.tsx b/servers/nextjs/app/(presentation-generator)/upload/components/UploadPage.tsx index f9689180..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, usePathname, useSearchParams } 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"; @@ -39,7 +39,6 @@ interface LoadingState { const UploadPage = () => { const router = useRouter(); const pathname = usePathname(); - const searchParams = useSearchParams(); const dispatch = useDispatch(); // State management diff --git a/servers/nextjs/app/MixpanelInitializer.tsx b/servers/nextjs/app/MixpanelInitializer.tsx index 65b23fd6..caccf109 100644 --- a/servers/nextjs/app/MixpanelInitializer.tsx +++ b/servers/nextjs/app/MixpanelInitializer.tsx @@ -1,26 +1,20 @@ 'use client'; import { useEffect } from 'react'; -import { usePathname, useSearchParams } from 'next/navigation'; -import { initMixpanel, trackEvent, MixpanelEvent } from '@/utils/mixpanel'; +import { initMixpanel, MixpanelEvent, trackEvent } from '@/utils/mixpanel'; +import { usePathname } from 'next/navigation'; export function MixpanelInitializer({ children }: { children: React.ReactNode }) { const pathname = usePathname(); - const searchParams = useSearchParams(); // Initialize once useEffect(() => { initMixpanel(); }, []); - // Track page views on route changes useEffect(() => { - if (!pathname) return; - const query = searchParams?.toString(); - const url = query ? `${pathname}?${query}` : pathname; - trackEvent(MixpanelEvent.PageView, { url }); - }, [pathname, searchParams]); - + trackEvent(MixpanelEvent.PageView, { url: pathname }); + }, [pathname]); return <>{children}; } diff --git a/servers/nextjs/components/Home.tsx b/servers/nextjs/components/Home.tsx index 414e78cc..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"; @@ -13,7 +13,7 @@ import { } from "@/utils/providerUtils"; import { LLMConfig } from "@/types/llm_config"; import { trackEvent, MixpanelEvent } from "@/utils/mixpanel"; -import { usePathname, useSearchParams } from "next/navigation"; +import { usePathname } from "next/navigation"; // Button state interface interface ButtonState { @@ -28,7 +28,6 @@ interface ButtonState { export default function Home() { const router = useRouter(); const pathname = usePathname(); - const searchParams = useSearchParams(); const config = useSelector((state: RootState) => state.userConfig); const [llmConfig, setLlmConfig] = useState(config.llm_config); @@ -56,9 +55,7 @@ export default function Home() { }, [downloadingModel?.downloaded, downloadingModel?.size]); const handleSaveConfig = async () => { - // Track button click with pathname and searchParams - const query = searchParams?.toString(); - trackEvent(MixpanelEvent.Home_SaveConfiguration_Button_Clicked, { pathname, query }); + trackEvent(MixpanelEvent.Home_SaveConfiguration_Button_Clicked, { pathname }); try { setButtonState(prev => ({ ...prev, diff --git a/servers/nextjs/utils/mixpanel.ts b/servers/nextjs/utils/mixpanel.ts index 0261861f..da03f2ec 100644 --- a/servers/nextjs/utils/mixpanel.ts +++ b/servers/nextjs/utils/mixpanel.ts @@ -11,7 +11,8 @@ export enum MixpanelEvent { 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', - Generate_Presentation_Button_Clicked = 'Generate Presentation Button Clicked', + 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', Group_Layout_Selected_Clicked = 'Group Layout Selected Clicked', Header_Export_PDF_Button_Clicked = 'Header Export PDF Button Clicked', @@ -24,6 +25,7 @@ export enum MixpanelEvent { 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', From 5f7927598520314bc40411e32390ee09b8bebfa0 Mon Sep 17 00:00:00 2001 From: sauravniraula Date: Wed, 13 Aug 2025 13:31:33 +0545 Subject: [PATCH 11/13] chore: adds ppt prepare and stream api call events --- .../outline/hooks/usePresentationGeneration.ts | 10 ++++++---- .../presentation/hooks/usePresentationStreaming.ts | 5 ++++- servers/nextjs/utils/mixpanel.ts | 2 ++ 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/servers/nextjs/app/(presentation-generator)/outline/hooks/usePresentationGeneration.ts b/servers/nextjs/app/(presentation-generator)/outline/hooks/usePresentationGeneration.ts index 3b80204b..6743dcd1 100644 --- a/servers/nextjs/app/(presentation-generator)/outline/hooks/usePresentationGeneration.ts +++ b/servers/nextjs/app/(presentation-generator)/outline/hooks/usePresentationGeneration.ts @@ -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); diff --git a/servers/nextjs/app/(presentation-generator)/presentation/hooks/usePresentationStreaming.ts b/servers/nextjs/app/(presentation-generator)/presentation/hooks/usePresentationStreaming.ts index b28fe3ca..23d3c80f 100644 --- a/servers/nextjs/app/(presentation-generator)/presentation/hooks/usePresentationStreaming.ts +++ b/servers/nextjs/app/(presentation-generator)/presentation/hooks/usePresentationStreaming.ts @@ -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; } }); diff --git a/servers/nextjs/utils/mixpanel.ts b/servers/nextjs/utils/mixpanel.ts index da03f2ec..3f51de71 100644 --- a/servers/nextjs/utils/mixpanel.ts +++ b/servers/nextjs/utils/mixpanel.ts @@ -14,6 +14,8 @@ export enum MixpanelEvent { 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', From 667415997e1d0de06338cb6fd6e4f8d2bcf18394 Mon Sep 17 00:00:00 2001 From: sauravniraula Date: Wed, 13 Aug 2025 13:59:09 +0545 Subject: [PATCH 12/13] chore: uses proper naming for vars and functions --- README.md | 4 +-- .../nextjs/app/api/telemetry-status/route.ts | 11 ++++++++ .../nextjs/app/api/tracking-status/route.ts | 11 -------- servers/nextjs/utils/mixpanel.ts | 28 +++++++++---------- 4 files changed, 27 insertions(+), 27 deletions(-) create mode 100644 servers/nextjs/app/api/telemetry-status/route.ts delete mode 100644 servers/nextjs/app/api/tracking-status/route.ts diff --git a/README.md b/README.md index 8b039980..b82ae3c9 100644 --- a/README.md +++ b/README.md @@ -104,8 +104,8 @@ 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 tracking using the following environment variable: -- **DISABLE_ANONYMOUS_TRACKING=[true/false]**: Set this to **true** to disable anonymous usage tracking. +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). 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/api/tracking-status/route.ts b/servers/nextjs/app/api/tracking-status/route.ts deleted file mode 100644 index 3cc727ff..00000000 --- a/servers/nextjs/app/api/tracking-status/route.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { NextResponse } from 'next/server'; - -export const dynamic = 'force-dynamic'; - -export async function GET() { - const isDisabled = process.env.DISABLE_ANONYMOUS_TRACKING === 'true' || process.env.DISABLE_ANONYMOUS_TRACKING === 'True'; - const trackingEnabled = !isDisabled; - return NextResponse.json({ trackingEnabled }); -} - - diff --git a/servers/nextjs/utils/mixpanel.ts b/servers/nextjs/utils/mixpanel.ts index 3f51de71..7099e3e4 100644 --- a/servers/nextjs/utils/mixpanel.ts +++ b/servers/nextjs/utils/mixpanel.ts @@ -57,7 +57,7 @@ export type MixpanelProps = Record; declare global { interface Window { __mixpanel_initialized?: boolean; - __mixpanel_tracking_enabled?: boolean; + __mixpanel_telemetry_enabled?: boolean; } } @@ -67,28 +67,28 @@ function canUseMixpanel(): boolean { let trackingCheckPromise: Promise | null = null; -async function ensureTrackingStatus(): Promise { +async function ensureTelemetryStatus(): Promise { if (typeof window === 'undefined') return false; - if (typeof window.__mixpanel_tracking_enabled === 'boolean') { - return window.__mixpanel_tracking_enabled; + if (typeof window.__mixpanel_telemetry_enabled === 'boolean') { + return window.__mixpanel_telemetry_enabled; } if (!trackingCheckPromise) { - trackingCheckPromise = fetch('/api/tracking-status') + trackingCheckPromise = fetch('/api/telemetry-status') .then(async (res) => { try { const data = await res.json(); - const enabled = Boolean(data?.trackingEnabled); - window.__mixpanel_tracking_enabled = enabled; + 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_tracking_enabled = true; + window.__mixpanel_telemetry_enabled = true; return true; } }) .catch(() => { // If the API call fails, default to enabling tracking - window.__mixpanel_tracking_enabled = true; + window.__mixpanel_telemetry_enabled = true; return true; }); } @@ -98,8 +98,8 @@ async function ensureTrackingStatus(): Promise { export function initMixpanel(): void { if (!canUseMixpanel()) return; if (window.__mixpanel_initialized) return; - // Ensure tracking is allowed before initializing - void ensureTrackingStatus().then((enabled) => { + // 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 }); @@ -110,7 +110,7 @@ export function initMixpanel(): void { export function track(eventName: string, props?: Record): void { if (!canUseMixpanel()) return; - if (typeof window !== 'undefined' && window.__mixpanel_tracking_enabled === false) { + if (typeof window !== 'undefined' && window.__mixpanel_telemetry_enabled === false) { return; } if (!window.__mixpanel_initialized) { @@ -126,7 +126,7 @@ export function trackEvent(event: MixpanelEvent, props?: MixpanelProps): void { export function getDistinctId(): string | undefined { if (!canUseMixpanel()) return undefined; - if (typeof window !== 'undefined' && window.__mixpanel_tracking_enabled === false) { + if (typeof window !== 'undefined' && window.__mixpanel_telemetry_enabled === false) { return undefined; } if (!window.__mixpanel_initialized) { @@ -139,7 +139,7 @@ export function getDistinctId(): string | undefined { export function identifyAnonymous(): void { if (!canUseMixpanel()) return; - if (typeof window !== 'undefined' && window.__mixpanel_tracking_enabled === false) { + if (typeof window !== 'undefined' && window.__mixpanel_telemetry_enabled === false) { return; } if (!window.__mixpanel_initialized) { From 3415208a7c33f60aac2fb887ef1478f98f2e07db Mon Sep 17 00:00:00 2001 From: Suraj Jha Date: Wed, 13 Aug 2025 17:54:47 +0545 Subject: [PATCH 13/13] fix: stuck template generation --- servers/fastapi/api/v1/ppt/endpoints/pptx_slides.py | 2 +- .../custom-template/hooks/useSlideProcessing.ts | 12 +++++++++++- .../custom-template/page.tsx | 1 - 3 files changed, 12 insertions(+), 3 deletions(-) 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/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 b77ded5d..3c23d26f 100644 --- a/servers/nextjs/app/(presentation-generator)/custom-template/page.tsx +++ b/servers/nextjs/app/(presentation-generator)/custom-template/page.tsx @@ -82,7 +82,6 @@ const CustomTemplatePage = () => { } }, []); - // Loading state if (isRequiredKeyLoading) { return ;