diff --git a/nginx.conf b/nginx.conf index b5ae9172..a61a9c9b 100644 --- a/nginx.conf +++ b/nginx.conf @@ -96,5 +96,11 @@ http { expires 1y; add_header Cache-Control "public, immutable"; } + + location /app_data/pptx-to-html/ { + alias /app_data/pptx-to-html/; + expires 1y; + add_header Cache-Control "public, immutable"; + } } } \ No newline at end of file diff --git a/servers/fastapi/api/v1/ppt/endpoints/pdf_slides.py b/servers/fastapi/api/v1/ppt/endpoints/pdf_slides.py index 606cb12f..4906df87 100644 --- a/servers/fastapi/api/v1/ppt/endpoints/pdf_slides.py +++ b/servers/fastapi/api/v1/ppt/endpoints/pdf_slides.py @@ -99,7 +99,7 @@ async def process_pdf_slides( ) else: # Fallback if screenshot generation failed or file is empty placeholder - screenshot_url = "/static/images/placeholder.jpg" + screenshot_url = "/static/images/replaceable_template_image.png" slides_data.append( PdfSlideData(slide_number=i, screenshot_url=screenshot_url) diff --git a/servers/fastapi/api/v1/ppt/endpoints/pptx_slides.py b/servers/fastapi/api/v1/ppt/endpoints/pptx_slides.py index b4c4acae..6ef33574 100644 --- a/servers/fastapi/api/v1/ppt/endpoints/pptx_slides.py +++ b/servers/fastapi/api/v1/ppt/endpoints/pptx_slides.py @@ -378,7 +378,7 @@ async def process_pptx_slides( ) else: # Fallback if screenshot generation failed or file is empty placeholder - screenshot_url = "/static/images/placeholder.jpg" + screenshot_url = "/static/images/replaceable_template_image.png" # Compute normalized fonts for this slide raw_slide_fonts = extract_fonts_from_oxml(xml_content) diff --git a/servers/fastapi/services/export_task_service.py b/servers/fastapi/services/export_task_service.py index 8e9c8ac2..ca31c588 100644 --- a/servers/fastapi/services/export_task_service.py +++ b/servers/fastapi/services/export_task_service.py @@ -90,9 +90,6 @@ class ExportTaskService: def _build_node_env(self) -> Mapping[str, str]: env = os.environ.copy() - binary_name = os.path.basename(self.node_binary).lower() - if binary_name not in {"node", "node.exe"}: - env.setdefault("ELECTRON_RUN_AS_NODE", "1") app_data_directory = get_app_data_directory_env() if not app_data_directory: diff --git a/servers/fastapi/services/image_generation_service.py b/servers/fastapi/services/image_generation_service.py index 6b47d426..8d0ea9cb 100644 --- a/servers/fastapi/services/image_generation_service.py +++ b/servers/fastapi/services/image_generation_service.py @@ -69,11 +69,11 @@ class ImageGenerationService: """ if self.is_image_generation_disabled: print("Image generation is disabled. Using placeholder image.") - return "/static/images/placeholder.jpg" + return "/static/images/replaceable_template_image.png" if not self.image_gen_func: print("No image generation function found. Using placeholder image.") - return "/static/images/placeholder.jpg" + return "/static/images/replaceable_template_image.png" image_prompt = prompt.get_image_prompt( with_theme=not self.is_stock_provider_selected() @@ -107,7 +107,7 @@ class ImageGenerationService: except Exception as e: print(f"Error generating image: {e}") - return "/static/images/placeholder.jpg" + return "/static/images/replaceable_template_image.png" async def generate_image_openai( self, prompt: str, output_directory: str, model: str, quality: str diff --git a/servers/fastapi/services/liteparse_service.py b/servers/fastapi/services/liteparse_service.py index dca0835d..392a94c5 100644 --- a/servers/fastapi/services/liteparse_service.py +++ b/servers/fastapi/services/liteparse_service.py @@ -45,15 +45,8 @@ class LiteParseService: self._npm_project_root = self._resolve_npm_project_root() def _build_node_env(self) -> Dict[str, str]: - """Build environment for Node subprocesses. - - When the configured runtime binary is not the canonical `node` executable - (for example Electron's app binary), force Node-compatible mode. - """ + """Build environment for Node subprocesses.""" env = os.environ.copy() - binary_name = os.path.basename(self.node_binary).lower() - if binary_name not in {"node", "node.exe"}: - env.setdefault("ELECTRON_RUN_AS_NODE", "1") # LiteParse checks ImageMagick availability with `which magick`. # On macOS app launches, PATH often excludes Homebrew bins, even when @@ -97,36 +90,51 @@ class LiteParseService: return env def _resolve_npm_project_root(self) -> str: - """Directory whose node_modules contains @llamaindex/liteparse (runner dir or Electron app root).""" - local_nm = os.path.join( - self.runner_dir, "node_modules", "@llamaindex", "liteparse" - ) - if os.path.isdir(local_nm): - return self.runner_dir - electron_nm = os.path.abspath( - os.path.join(self.runner_dir, "..", "..", "node_modules", "@llamaindex", "liteparse") - ) - if os.path.isdir(electron_nm): - return os.path.abspath(os.path.join(self.runner_dir, "..", "..")) - return os.path.abspath(os.path.join(self.runner_dir, "..", "..")) + """Directory whose node_modules contains @llamaindex/liteparse.""" + candidates = [ + self.runner_dir, + os.path.abspath(os.path.join(self.runner_dir, "..")), + os.path.abspath(os.path.join(os.getcwd(), "..", "..", "document-extraction-liteparse")), + os.path.abspath(os.path.join(os.getcwd(), "..", "..")), + "/app/document-extraction-liteparse", + "/app", + ] + + fallback = candidates[0] + for candidate in candidates: + if os.path.isdir(candidate): + fallback = candidate + local_nm = os.path.join(candidate, "node_modules", "@llamaindex", "liteparse") + if os.path.isdir(local_nm): + return candidate + + return fallback @staticmethod def _resolve_runner_path() -> str: cwd = os.path.abspath(".") + service_dir = os.path.dirname(__file__) candidates = [ - # electron/servers/fastapi → electron/resources/... - os.path.abspath( - os.path.join( - cwd, "..", "..", "resources", "document-extraction", "liteparse_runner.mjs" - ) - ), - # servers/fastapi (repo root layout) → electron/resources/... + # Dedicated Docker runtime path + "/app/document-extraction-liteparse/liteparse_runner.mjs", + # servers/fastapi (repo root layout) → resources/... os.path.abspath( os.path.join( cwd, "..", "..", - "electron", + "resources", + "document-extraction", + "liteparse_runner.mjs", + ) + ), + # services/liteparse_service.py → resources/... + os.path.abspath( + os.path.join( + service_dir, + "..", + "..", + "..", "resources", "document-extraction", "liteparse_runner.mjs", @@ -138,8 +146,6 @@ class LiteParseService: cwd, "..", "..", "app", "resources", "document-extraction", "liteparse_runner.mjs" ) ), - # Docker / explicit layout - "/app/document-extraction-liteparse/liteparse_runner.mjs", ] for path in candidates: if os.path.isfile(path): @@ -169,7 +175,7 @@ class LiteParseService: if not os.path.isdir(liteparse_dir): return ( False, - f"LiteParse npm package missing at {liteparse_dir}. Run npm install in the Electron app directory.", + f"LiteParse npm package missing at {liteparse_dir}. Install @llamaindex/liteparse in the runtime project root.", ) # @llamaindex/liteparse is ESM-only; require.resolve() fails. Use dynamic import. diff --git a/servers/fastapi/services/pptx_presentation_creator.py b/servers/fastapi/services/pptx_presentation_creator.py index d3e51a7f..e0f179da 100644 --- a/servers/fastapi/services/pptx_presentation_creator.py +++ b/servers/fastapi/services/pptx_presentation_creator.py @@ -220,7 +220,7 @@ class PptxPresentationCreator: each_shape.picture.path = os.path.join("/app_data", relative_path) each_shape.picture.is_network = False return - # Resolve HTTP URLs that contain absolute filesystem paths (Mac/Electron) + # Resolve HTTP URLs that contain absolute filesystem paths. local_path = resolve_image_path_to_filesystem(image_path) if local_path: each_shape.picture.path = local_path @@ -315,7 +315,7 @@ class PptxPresentationCreator: def add_picture(self, slide: Slide, picture_model: PptxPictureBoxModel): image_path = picture_model.picture.path - # Resolve /app_data/... to actual filesystem path (Electron) + # Resolve /app_data/... to actual filesystem path. if image_path.startswith("/app_data/"): app_data_dir = get_app_data_directory_env() if app_data_dir: diff --git a/servers/fastapi/static/images/replaceable_template_image.png b/servers/fastapi/static/images/replaceable_template_image.png new file mode 100644 index 00000000..838d7f9d Binary files /dev/null and b/servers/fastapi/static/images/replaceable_template_image.png differ diff --git a/servers/fastapi/templates/preview.py b/servers/fastapi/templates/preview.py index 0a045319..06ae0e90 100644 --- a/servers/fastapi/templates/preview.py +++ b/servers/fastapi/templates/preview.py @@ -367,7 +367,7 @@ async def store_slide_images( await asyncio.to_thread(_copy_file, screenshot_path, destination_path) slide_image_urls.append(f"/app_data/images/{session_id}/{file_name}") else: - slide_image_urls.append("/static/images/placeholder.jpg") + slide_image_urls.append("/static/images/replaceable_template_image.png") return slide_image_urls diff --git a/servers/fastapi/tests/test_image_generation.py b/servers/fastapi/tests/test_image_generation.py index 56a602b4..1c82f8d8 100644 --- a/servers/fastapi/tests/test_image_generation.py +++ b/servers/fastapi/tests/test_image_generation.py @@ -195,7 +195,7 @@ class TestImageGenerationService: result = await service.generate_image(sample_image_prompt) # Should return placeholder - assert result == "/static/images/placeholder.jpg" + assert result == "/static/images/replaceable_template_image.png" asyncio.run(run_test()) @@ -221,7 +221,7 @@ class TestImageGenerationService: result = await service.generate_image(sample_image_prompt) - assert result == "/static/images/placeholder.jpg" + assert result == "/static/images/replaceable_template_image.png" asyncio.run(run_test()) @@ -367,7 +367,7 @@ class TestImageGenerationEndpoint: with patch('api.v1.ppt.endpoints.images.get_images_directory', return_value=mock_images_directory): with patch('api.v1.ppt.endpoints.images.ImageGenerationService') as mock_service_class: mock_service_instance = Mock() - mock_service_instance.generate_image = AsyncMock(return_value="/static/images/placeholder.jpg") + mock_service_instance.generate_image = AsyncMock(return_value="/static/images/replaceable_template_image.png") mock_service_class.return_value = mock_service_instance response = client.get(f"/images/generate?prompt={test_prompt}") diff --git a/servers/fastapi/tests/test_slide_to_html.py b/servers/fastapi/tests/test_slide_to_html.py index 63f9fe92..6aefdae3 100644 --- a/servers/fastapi/tests/test_slide_to_html.py +++ b/servers/fastapi/tests/test_slide_to_html.py @@ -42,7 +42,7 @@ def test_slide_to_html_endpoint(): # Use a placeholder image path (since we can't easily test with real files) test_data = { - "image": "/static/images/placeholder.jpg", + "image": "/static/images/replaceable_template_image.png", "xml": test_xml } @@ -92,7 +92,7 @@ def test_slide_to_html_missing_xml(): """Test the endpoint with missing XML data.""" test_data = { - "image": "/static/images/placeholder.jpg" + "image": "/static/images/replaceable_template_image.png" # No XML data provided } diff --git a/servers/fastapi/utils/asset_directory_utils.py b/servers/fastapi/utils/asset_directory_utils.py index 5f8f13f8..28eca9ee 100644 --- a/servers/fastapi/utils/asset_directory_utils.py +++ b/servers/fastapi/utils/asset_directory_utils.py @@ -12,7 +12,7 @@ def resolve_app_path_to_filesystem(path_or_url: str) -> Optional[str]: Handles: - Path strings: /app_data/images/..., /static/..., absolute paths, relative - file:// URLs returned by export runtimes - - HTTP URLs whose path component is an absolute filesystem path (Mac/Electron): + - HTTP URLs whose path component is an absolute filesystem path: When img src is /Users/.../images/xxx.png, browser resolves to http://origin/Users/.../images/xxx.png. Next.js returns 404 for these. diff --git a/servers/fastapi/utils/oauth/openai_codex.py b/servers/fastapi/utils/oauth/openai_codex.py index c94b75eb..47bee191 100644 --- a/servers/fastapi/utils/oauth/openai_codex.py +++ b/servers/fastapi/utils/oauth/openai_codex.py @@ -402,7 +402,7 @@ class _CallbackHandler(BaseHTTPRequestHandler): self.wfile.write(b"Missing authorization code") return - # In the desktop/Electron app context the redirect URI is a localhost-only + # In local callback flows the redirect URI is localhost-only. # callback, so strict CSRF protection via state comparison is less critical. # We've seen intermittent state mismatches in the field (likely from # overlapping auth attempts or stale callback servers), so we treat a diff --git a/servers/fastapi/utils/ocr_language.py b/servers/fastapi/utils/ocr_language.py index aa988f27..bb4a4009 100644 --- a/servers/fastapi/utils/ocr_language.py +++ b/servers/fastapi/utils/ocr_language.py @@ -3,7 +3,7 @@ Map presentation UI language strings (LanguageType enum values from Next.js) to Tesseract / LiteParse OCR language codes (ISO 639-3 where applicable). Keep keys in sync with: -electron/servers/nextjs/app/(presentation-generator)/upload/type.ts → LanguageType +servers/nextjs/app/(presentation-generator)/upload/type.ts → LanguageType """ from __future__ import annotations diff --git a/servers/fastapi/utils/path_helpers.py b/servers/fastapi/utils/path_helpers.py index 424bceac..cf4548a7 100644 --- a/servers/fastapi/utils/path_helpers.py +++ b/servers/fastapi/utils/path_helpers.py @@ -1,7 +1,7 @@ """Paths relative to the FastAPI process working directory (Docker / local dev). The API is always started with cwd set to the `servers/fastapi` package root -(see start.js). No Electron, PyInstaller, or OS-specific layout handling. +(see start.js), without OS-specific layout handling. """ from __future__ import annotations diff --git a/servers/fastapi/utils/process_slides.py b/servers/fastapi/utils/process_slides.py index 616d4efb..9048ff68 100644 --- a/servers/fastapi/utils/process_slides.py +++ b/servers/fastapi/utils/process_slides.py @@ -200,7 +200,7 @@ def process_slide_add_placeholder_assets(slide: SlideModel): for image_path in image_paths: image_dict = get_dict_at_path(slide.content, image_path) # Use FastAPI static path for placeholder image - image_dict["__image_url__"] = "/static/images/placeholder.jpg" + image_dict["__image_url__"] = "/static/images/replaceable_template_image.png" set_dict_at_path(slide.content, image_path, image_dict) for icon_path in icon_paths: diff --git a/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/PrivacySettings.tsx b/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/PrivacySettings.tsx index c16e5f84..dfed7701 100644 --- a/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/PrivacySettings.tsx +++ b/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/PrivacySettings.tsx @@ -11,14 +11,9 @@ const PrivacySettings = () => { useEffect(() => { async function fetchStatus() { try { - if (window.electron?.telemetryStatus) { - const data = await window.electron.telemetryStatus(); - setTrackingEnabled(data.telemetryEnabled); - } else { - const res = await fetch("/api/telemetry-status"); - const data = await res.json(); - setTrackingEnabled(data.telemetryEnabled); - } + const res = await fetch("/api/telemetry-status"); + const data = await res.json(); + setTrackingEnabled(data.telemetryEnabled); } catch { setTrackingEnabled(true); } @@ -32,18 +27,12 @@ const PrivacySettings = () => { setTelemetryEnabled(enabled); setSaving(true); try { - if (window.electron?.setUserConfig) { - await window.electron.setUserConfig({ + await fetch("/api/user-config", { + method: "POST", + body: JSON.stringify({ DISABLE_ANONYMOUS_TRACKING: enabled ? undefined : "true", - } as any); - } else { - await fetch("/api/user-config", { - method: "POST", - body: JSON.stringify({ - DISABLE_ANONYMOUS_TRACKING: enabled ? undefined : "true", - }), - }); - } + }), + }); } catch { setTrackingEnabled(prev); setTelemetryEnabled(prev ?? true); diff --git a/servers/nextjs/app/(presentation-generator)/outline/components/CustomTemplateCard.tsx b/servers/nextjs/app/(presentation-generator)/outline/components/CustomTemplateCard.tsx index b052d2ec..ffa4368e 100644 --- a/servers/nextjs/app/(presentation-generator)/outline/components/CustomTemplateCard.tsx +++ b/servers/nextjs/app/(presentation-generator)/outline/components/CustomTemplateCard.tsx @@ -15,7 +15,7 @@ export const CustomTemplateCard = memo(function CustomTemplateCard({ selectedTemplate, }: { template: CustomTemplates; - onSelectTemplate: (template: CustomTemplates) => void; + onSelectTemplate: (template: string) => void; selectedTemplate: string | null; }) { const { previewLayouts, loading } = useCustomTemplatePreview(template.id); @@ -29,7 +29,7 @@ export const CustomTemplateCard = memo(function CustomTemplateCard({ ? " border-blue-500 ring-2 ring-blue-500/25 shadow-sm" : " border-[#E8E9EC]" )} - onClick={() => onSelectTemplate(template)} + onClick={() => onSelectTemplate(template.id)} > diff --git a/servers/nextjs/app/(presentation-generator)/outline/components/TemplateSelection.tsx b/servers/nextjs/app/(presentation-generator)/outline/components/TemplateSelection.tsx index 48710796..4bf94643 100644 --- a/servers/nextjs/app/(presentation-generator)/outline/components/TemplateSelection.tsx +++ b/servers/nextjs/app/(presentation-generator)/outline/components/TemplateSelection.tsx @@ -7,8 +7,6 @@ import { Card } from "@/components/ui/card"; import { cn } from "@/lib/utils"; import { CustomTemplates, useCustomTemplateSummaries } from "@/app/hooks/useCustomTemplates"; import { Loader2 } from "lucide-react"; -import { usePathname } from "next/navigation"; -import { trackEvent, MixpanelEvent } from "@/utils/mixpanel"; import CreateCustomTemplate from "../../(dashboard)/templates/components/CreateCustomTemplate"; import { CustomTemplateCard } from "./CustomTemplateCard"; @@ -66,8 +64,6 @@ const TemplateSelection: React.FC = memo(function Templa selectedTemplate, onSelectTemplate, }) { - const pathname = usePathname(); - useEffect(() => { const existingScript = document.querySelector( 'script[src*="tailwindcss.com"]' @@ -83,31 +79,13 @@ const TemplateSelection: React.FC = memo(function Templa const { templates: customTemplates, loading: customLoading } = useCustomTemplateSummaries(); const handleCustomSelect = useCallback( - (template: CustomTemplates) => { - trackEvent(MixpanelEvent.Outline_Template_Selected, { - pathname, - template_type: "custom", - template_id: template.id, - template_name: template.name, - layout_count: template.layoutCount, - }); - onSelectTemplate(template.id); - }, - [onSelectTemplate, pathname] + (template: TemplateLayoutsWithSettings | string) => onSelectTemplate(template), + [onSelectTemplate] ); const handleBuiltInSelect = useCallback( - (template: TemplateLayoutsWithSettings) => { - trackEvent(MixpanelEvent.Outline_Template_Selected, { - pathname, - template_type: "built_in", - template_id: template.id, - template_name: template.name, - layout_count: template.layouts.length, - }); - onSelectTemplate(template); - }, - [onSelectTemplate, pathname] + (template: TemplateLayoutsWithSettings) => onSelectTemplate(template), + [onSelectTemplate] ); const selectedCustomId = useMemo( diff --git a/servers/nextjs/app/(presentation-generator)/pdf-maker/PdfMakerPage.tsx b/servers/nextjs/app/(presentation-generator)/pdf-maker/PdfMakerPage.tsx index 3575d4e4..cfdf172e 100644 --- a/servers/nextjs/app/(presentation-generator)/pdf-maker/PdfMakerPage.tsx +++ b/servers/nextjs/app/(presentation-generator)/pdf-maker/PdfMakerPage.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; import { RootState } from "@/store/store"; +import "../utils/prism-languages"; import { Skeleton } from "@/components/ui/skeleton"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -10,6 +11,7 @@ import { trackEvent, MixpanelEvent } from "@/utils/mixpanel"; import { AlertCircle } from "lucide-react"; import { setPresentationData } from "@/store/slices/presentationGeneration"; import { DashboardApi } from "../services/api/dashboard"; +import { setupImageUrlConverter } from "@/utils/image-url-converter"; import { V1ContentRender } from "../components/V1ContentRender"; @@ -43,6 +45,13 @@ const PresentationPage = ({ presentation_id }: { presentation_id: string }) => { } } }, [presentationData]); + + // Ensure /app_data and /static image paths resolve through the backend origin. + useEffect(() => { + const observer = setupImageUrlConverter(); + return () => observer?.disconnect(); + }, []); + // Function to fetch the slides useEffect(() => { fetchUserSlides(); @@ -54,12 +63,18 @@ const PresentationPage = ({ presentation_id }: { presentation_id: string }) => { const data = await DashboardApi.getPresentation(presentation_id); dispatch(setPresentationData(data)); setContentLoading(false); - if (data?.theme) { - applyTheme(data.theme); - } + if (data.fonts) { useFontLoader(data.fonts); } + if (data?.theme) { + try { + applyTheme(data.theme); + } catch (themeError) { + // Theme issues should not block export rendering. + console.warn("Theme application skipped for pdf-maker:", themeError); + } + } } catch (error) { setError(true); toast.error("Failed to load presentation"); @@ -73,6 +88,7 @@ const PresentationPage = ({ presentation_id }: { presentation_id: string }) => { if (!element) return; if (!theme || !theme.data) { return; } if (!theme.data.colors['graph_0']) { return; } + if (!theme.data.fonts?.textFont?.name || !theme.data.fonts?.textFont?.url) { return; } const cssVariables = { '--primary-color': theme.data.colors['primary'], '--background-color': theme.data.colors['background'], @@ -137,7 +153,7 @@ const PresentationPage = ({ presentation_id }: { presentation_id: string }) => {
{!presentationData || @@ -161,8 +177,12 @@ const PresentationPage = ({ presentation_id }: { presentation_id: string }) => { 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 -
- +
))} diff --git a/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationHeader.tsx b/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationHeader.tsx index 6111dfde..8d253464 100644 --- a/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationHeader.tsx +++ b/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationHeader.tsx @@ -150,20 +150,6 @@ const PresentationHeader = ({ return pptx_model; }; - const exportViaIpc = async (format: "pptx" | "pdf"): Promise => { - if (typeof window === 'undefined') return false; - if (!(window as any).electron?.exportPresentation) return false; - const result = await (window as any).electron.exportPresentation( - presentation_id, - presentationData?.title || 'presentation', - format - ); - if (!result?.success) { - throw new Error(result?.message || 'Export failed'); - } - return true; - }; - const handleExportPptx = async () => { if (isStreaming) return; @@ -179,11 +165,6 @@ const PresentationHeader = ({ // Save the presentation data before exporting await PresentationGenerationApi.updatePresentationContent(presentationData); - if (await exportViaIpc("pptx")) { - toast.success("PPTX exported successfully!"); - return; - } - const pptx_model = await get_presentation_pptx_model(presentation_id); if (!pptx_model) { throw new Error("Failed to get presentation PPTX model"); @@ -220,11 +201,6 @@ const PresentationHeader = ({ setIsExporting(true); // Save the presentation data before exporting await PresentationGenerationApi.updatePresentationContent(presentationData); - - if (await exportViaIpc("pdf")) { - toast.success("PDF exported successfully!"); - return; - } const response = await fetch('/api/export-as-pdf', { method: 'POST', body: JSON.stringify({ diff --git a/servers/nextjs/app/(presentation-generator)/template-preview/components/TemplatePreviewClient.tsx b/servers/nextjs/app/(presentation-generator)/template-preview/components/TemplatePreviewClient.tsx index 703128c5..f380fd65 100644 --- a/servers/nextjs/app/(presentation-generator)/template-preview/components/TemplatePreviewClient.tsx +++ b/servers/nextjs/app/(presentation-generator)/template-preview/components/TemplatePreviewClient.tsx @@ -12,6 +12,7 @@ import Header from "../../(dashboard)/dashboard/components/Header"; import { toast } from "sonner"; import { CustomTemplateLayout, useCustomTemplateDetails } from "@/app/hooks/useCustomTemplates"; import { templates as templateGroups, getTemplatesByTemplateName } from "@/app/presentation-templates"; +import { setupImageUrlConverter } from "@/utils/image-url-converter"; const GroupLayoutPreview = () => { const searchParams = useSearchParams(); @@ -43,6 +44,12 @@ const GroupLayoutPreview = () => { } }, [templateParams]); + // Keep backend-served assets on the active origin in Docker/nginx preview mode. + useEffect(() => { + const observer = setupImageUrlConverter(); + return () => observer?.disconnect(); + }, []); + const handleDeleteCustomTemplate = async () => { if (!customTemplateId) return; 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 b1ff1843..7e126b6d 100644 --- a/servers/nextjs/app/api/presentation_to_pptx_model/route.ts +++ b/servers/nextjs/app/api/presentation_to_pptx_model/route.ts @@ -30,6 +30,22 @@ interface GetAllChildElementsAttributesArgs { } export async function GET(request: NextRequest) { + // This route requires server-side Puppeteer execution and is unavailable in static/edge builds. + const isStaticMode = + process.env.IS_STATIC_EXPORT === "true" || + process.env.NEXT_RUNTIME === "edge"; + + if (isStaticMode) { + return NextResponse.json( + { + error: "This API route requires a server runtime and is not available in static export mode", + message: "This functionality is only available in server deployments with Puppeteer support" + }, + { status: 501 } + ); + } + + // Full functionality for development/Docker let browser: Browser | null = null; let page: Page | null = null; @@ -424,11 +440,26 @@ async function getAllChildElementsAttributes({ ); }); - for (const { attributes } of elementsWithRootPosition) { - if (attributes.background && attributes.background.color) { - backgroundColor = attributes.background.color; - break; - } + const rootBackgroundCandidates = elementsWithRootPosition + .filter(({ attributes }) => { + return Boolean(attributes.background && attributes.background.color); + }) + .sort((a, b) => { + const zIndexA = a.attributes.zIndex || 0; + const zIndexB = b.attributes.zIndex || 0; + + // Prefer deeper nodes when z-index is tied so nested full-size backgrounds + // can override outer wrappers. + if (zIndexA === zIndexB) { + return b.depth - a.depth; + } + + // Prefer lower z-index candidates for slide background. + return zIndexA - zIndexB; + }); + + if (rootBackgroundCandidates.length > 0) { + backgroundColor = rootBackgroundCandidates[0].attributes.background?.color; } } diff --git a/servers/nextjs/app/hooks/compileLayout.ts b/servers/nextjs/app/hooks/compileLayout.ts index cf946190..b1110a06 100644 --- a/servers/nextjs/app/hooks/compileLayout.ts +++ b/servers/nextjs/app/hooks/compileLayout.ts @@ -5,6 +5,7 @@ import * as z from "zod"; import * as Recharts from "recharts"; import * as Babel from "@babel/standalone"; import * as d3 from "d3"; +import { resolveBackendAssetUrl } from "@/utils/api"; // import * as d3Cloud from "d3-cloud"; export interface CompiledLayout { @@ -17,14 +18,55 @@ export interface CompiledLayout { schemaJSON: any; } +function isLikelyBackendAssetPath(value: string): boolean { + if (!value) return false; + if (value.startsWith("file://")) return true; + if (value.startsWith("/app_data/") || value.startsWith("/static/")) return true; + if (value.startsWith("app_data/") || value.startsWith("static/")) return true; + return value.includes("/app_data/") || value.includes("/static/"); +} + +function normalizeLayoutAssetUrls(value: T): T { + if (typeof value === "string") { + const trimmedValue = value.trim(); + if (!isLikelyBackendAssetPath(trimmedValue)) { + return value; + } + return resolveBackendAssetUrl(trimmedValue) as T; + } + + if (Array.isArray(value)) { + return value.map((item) => normalizeLayoutAssetUrls(item)) as T; + } + + if (value && typeof value === "object") { + const normalizedEntries = Object.entries(value as Record).map( + ([key, item]) => [key, normalizeLayoutAssetUrls(item)] + ); + return Object.fromEntries(normalizedEntries) as T; + } + + return value; +} + +function normalizeHardcodedBackendUrlsInCode(layoutCode: string): string { + // Keep /app_data and /static paths origin-agnostic so nginx can proxy them. + return layoutCode.replace( + /https?:\/\/(?:127\.0\.0\.1|localhost|0\.0\.0\.0):(?:8000|5000)(?=\/(?:app_data|static)\/)/g, + "" + ); +} + /** * Compiles a layout code string into a usable React component */ export function compileCustomLayout(layoutCode: string): CompiledLayout | null { console.log('compileCustomLayout called'); try { + const normalizedLayoutCode = normalizeHardcodedBackendUrlsInCode(layoutCode); + // Clean up imports that we'll provide ourselves - const cleanCode = layoutCode + const cleanCode = normalizedLayoutCode // Remove React imports .replace(/import\s+React\s*,?\s*\{?[^}]*\}?\s*from\s+['"]react['"];?/g, "") .replace(/import\s+\*\s+as\s+React\s+from\s+['"]react['"];?/g, "") @@ -101,11 +143,17 @@ export function compileCustomLayout(layoutCode: string): CompiledLayout | null { return null; } + const wrappedComponent: React.ComponentType<{ data: any }> = ({ data, ...props }) => { + const normalizedData = React.useMemo(() => normalizeLayoutAssetUrls(data), [data]); + return React.createElement(result.component, { ...(props as any), data: normalizedData }); + }; + wrappedComponent.displayName = `CompiledTemplateLayout(${result.layoutName || result.layoutId || "Custom"})`; + // Parse schema to get sample data let sampleData: Record = {}; if (result.Schema) { try { - sampleData = result.Schema.parse({}); + sampleData = normalizeLayoutAssetUrls(result.Schema.parse({})); } catch (e) { console.warn("Could not parse schema defaults:", e); } @@ -113,7 +161,7 @@ export function compileCustomLayout(layoutCode: string): CompiledLayout | null { const schemaJSON = z.toJSONSchema(result.Schema); return { - component: result.component, + component: wrappedComponent, layoutId: result.layoutId, layoutName: result.layoutName, layoutDescription: result.layoutDescription, diff --git a/servers/nextjs/app/hooks/useCustomTemplates.ts b/servers/nextjs/app/hooks/useCustomTemplates.ts index ebc451c0..0a4ccc2d 100644 --- a/servers/nextjs/app/hooks/useCustomTemplates.ts +++ b/servers/nextjs/app/hooks/useCustomTemplates.ts @@ -5,6 +5,9 @@ import { useState, useEffect, useCallback } from "react"; import { compileCustomLayout, CompiledLayout } from "./compileLayout"; import TemplateService from "../(presentation-generator)/services/api/template"; +/** + * API response types + */ export interface TemplateSummary { @@ -217,17 +220,15 @@ export function useCustomTemplateSummaries() { setError(null); const data: TemplateSummary[] = await TemplateService.getCustomTemplateSummaries(); - const mappedTemplates: CustomTemplates[] = data - .filter((item) => item.total_layouts && item.total_layouts > 0) - .map((item) => { - return { - id: item.id, - name: item.name || "Custom Template", - layoutCount: item.total_layouts, - isCustom: true as const, - }; - }); + const mappedTemplates: CustomTemplates[] = data.filter(item => item.total_layouts && item.total_layouts > 0).map((item) => { + return { + id: item.id, + name: item.name || "Custom Template", + layoutCount: item.total_layouts, + isCustom: true as const, + } + }); setTemplates(mappedTemplates); } catch (err) { @@ -391,6 +392,7 @@ export function useCustomTemplatePreview(presentationId: string) { try { setLoading(true); const data = await TemplateService.getCustomTemplateDetails(presentationId); + // Compile first 4 layouts for preview const compiled: CompiledLayout[] = []; const layoutsToPreview = data.layouts.slice(0, 4); diff --git a/servers/nextjs/components/OnBoarding/FinalStep.tsx b/servers/nextjs/components/OnBoarding/FinalStep.tsx index 5a445b61..cd589ff4 100644 --- a/servers/nextjs/components/OnBoarding/FinalStep.tsx +++ b/servers/nextjs/components/OnBoarding/FinalStep.tsx @@ -34,14 +34,9 @@ const FinalStep = () => { useEffect(() => { async function fetchStatus() { try { - if (window.electron?.telemetryStatus) { - const data = await window.electron.telemetryStatus(); - setTrackingEnabled(data.telemetryEnabled); - } else { - const res = await fetch('/api/telemetry-status'); - const data = await res.json(); - setTrackingEnabled(data.telemetryEnabled); - } + const res = await fetch('/api/telemetry-status'); + const data = await res.json(); + setTrackingEnabled(data.telemetryEnabled); } catch { setTrackingEnabled(true); } @@ -54,18 +49,12 @@ const FinalStep = () => { setTrackingEnabled(enabled); setTelemetryEnabled(enabled); try { - if (window.electron?.setUserConfig) { - await window.electron.setUserConfig({ + await fetch('/api/user-config', { + method: 'POST', + body: JSON.stringify({ DISABLE_ANONYMOUS_TRACKING: enabled ? undefined : 'true', - } as any); - } else { - await fetch('/api/user-config', { - method: 'POST', - body: JSON.stringify({ - DISABLE_ANONYMOUS_TRACKING: enabled ? undefined : 'true', - }), - }); - } + }), + }); } catch { setTrackingEnabled(prev); setTelemetryEnabled(prev ?? true); diff --git a/servers/nextjs/lib/run-bundled-pdf-export.ts b/servers/nextjs/lib/run-bundled-pdf-export.ts index c6715a25..015f4c10 100644 --- a/servers/nextjs/lib/run-bundled-pdf-export.ts +++ b/servers/nextjs/lib/run-bundled-pdf-export.ts @@ -156,7 +156,6 @@ export async function runBundledPdfExport(params: { env: { ...process.env, BUILT_PYTHON_MODULE_PATH: converter, - ELECTRON_RUN_AS_NODE: "0", }, }); const stderr: Buffer[] = []; diff --git a/servers/nextjs/next.config.mjs b/servers/nextjs/next.config.mjs index b0c0d0b0..3e25faf7 100644 --- a/servers/nextjs/next.config.mjs +++ b/servers/nextjs/next.config.mjs @@ -9,7 +9,7 @@ const nextConfig = { return [ { source: '/app_data/fonts/:path*', - destination: 'http://localhost:8000/app_data/fonts/:path*', + destination: 'http://localhost:5000/app_data/fonts/:path*', }, ]; }, diff --git a/servers/nextjs/types/global.d.ts b/servers/nextjs/types/global.d.ts index ff0087b4..8e6e14fc 100644 --- a/servers/nextjs/types/global.d.ts +++ b/servers/nextjs/types/global.d.ts @@ -13,29 +13,7 @@ interface TextFrameProps { // Add other properties as needed } -// Electron IPC types (optional in Docker/web; present when embedded in Electron) -interface ElectronAPI { - fileDownloaded: (filePath: string) => Promise; - exportAsPDF: (id: string, title: string) => Promise; - getUserConfig: () => Promise; - setUserConfig: (userConfig: any) => Promise; - getCanChangeKeys: () => Promise; - readFile: (filePath: string) => Promise<{ content: string }>; - getSlideMetadata: (url: string, theme: string, customColors?: any, tempDirectory?: string) => Promise; - getFooter: (userId: string) => Promise; - setFooter: (userId: string, properties: any) => Promise; - getTheme: (userId: string) => Promise; - setTheme: (userId: string, themeData: any) => Promise; - uploadImage: (file: Buffer) => Promise; - writeNextjsLog: (logData: string) => Promise; - clearNextjsLogs: () => Promise; - hasRequiredKey: () => Promise<{ hasKey: boolean }>; - telemetryStatus: () => Promise<{ telemetryEnabled: boolean }>; - getTemplates: () => Promise>; -} - interface Window { - electron?: ElectronAPI; env?: { NEXT_PUBLIC_FAST_API: string; NEXT_PUBLIC_URL: string; diff --git a/servers/nextjs/utils/api.ts b/servers/nextjs/utils/api.ts index 5ea72462..5c6c0203 100644 --- a/servers/nextjs/utils/api.ts +++ b/servers/nextjs/utils/api.ts @@ -1,47 +1,103 @@ -// Same-origin API and static assets: nginx proxies /api/v1, /static, /app_data to fixed internal ports. +// Utility to get the FastAPI base URL +export function getFastAPIUrl(): string { + if (process.env.NEXT_PUBLIC_FAST_API) { + return process.env.NEXT_PUBLIC_FAST_API; + } -function withLeadingSlash(path: string): string { - return path.startsWith("/") ? path : `/${path}`; + const queryFastApiUrl = getFastApiUrlFromQuery(); + if (queryFastApiUrl) { + return queryFastApiUrl; + } + + // Docker/web runtime: route backend assets and APIs through current origin + // (nginx reverse-proxies /api/v1, /app_data, /static). + if (typeof window !== "undefined") { + return window.location.origin; + } + + return "http://127.0.0.1:5000"; +} + +function getFastApiUrlFromQuery(): string | null { + if (typeof window === "undefined") return null; + try { + const params = new URLSearchParams(window.location.search); + const value = params.get("fastapiUrl"); + if (!value) return null; + + const parsed = new URL(value); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return null; + } + return parsed.origin; + } catch { + return null; + } } function isAbsoluteHttpUrl(path: string): boolean { return /^https?:\/\//i.test(path); } -/** Browser: current site origin. Server render: localhost FastAPI (dev only). */ -export function getFastAPIUrl(): string { - if (typeof window !== "undefined") { - return window.location.origin; - } - return "http://127.0.0.1:8000"; +function withLeadingSlash(path: string): string { + return path.startsWith("/") ? path : `/${path}`; } -/** Use relative URLs; nginx serves /api/v1 on the same host as the UI. */ +// Utility to construct API URL for Docker/web runtime. export function getApiUrl(path: string): string { if (isAbsoluteHttpUrl(path)) { return path; } - return withLeadingSlash(path); + + const normalizedPath = withLeadingSlash(path); + const isFastApiEndpoint = normalizedPath.startsWith("/api/v1/"); + const hasConfiguredFastApi = + !!process.env.NEXT_PUBLIC_FAST_API || !!getFastApiUrlFromQuery(); + + // In web/docker, /api/v1 is typically reverse-proxied by the web server. + // If a FastAPI origin is explicitly configured, use it instead of same-origin proxy. + if (isFastApiEndpoint && hasConfiguredFastApi) { + return `${getFastAPIUrl()}${normalizedPath}`; + } + + return normalizedPath; } -/** Keep /static and /app_data as same-origin paths the browser resolves. */ +function hasBackendAssetPrefix(path: string): boolean { + return path.startsWith("/static/") || path.startsWith("/app_data/"); +} + +// Resolve backend-served asset paths to the FastAPI origin. export function resolveBackendAssetUrl(path?: string): string { if (!path) return ""; - const trimmed = path.trim(); - if (!trimmed) return ""; + const trimmedPath = path.trim(); + if (!trimmedPath) return ""; if ( - trimmed.startsWith("data:") || - trimmed.startsWith("blob:") || - trimmed.startsWith("file:") + trimmedPath.startsWith("data:") || + trimmedPath.startsWith("blob:") || + trimmedPath.startsWith("file:") ) { - return trimmed; + return trimmedPath; } - if (isAbsoluteHttpUrl(trimmed)) { - return trimmed; + if (isAbsoluteHttpUrl(trimmedPath)) { + try { + const parsed = new URL(trimmedPath); + if (hasBackendAssetPrefix(parsed.pathname)) { + return `${getFastAPIUrl()}${parsed.pathname}${parsed.search}${parsed.hash}`; + } + return trimmedPath; + } catch { + return trimmedPath; + } } - return withLeadingSlash(trimmed); + const normalizedPath = withLeadingSlash(trimmedPath); + if (hasBackendAssetPrefix(normalizedPath)) { + return `${getFastAPIUrl()}${normalizedPath}`; + } + + return trimmedPath; } diff --git a/servers/nextjs/utils/mixpanel.ts b/servers/nextjs/utils/mixpanel.ts index c8c1ac82..17e4e12d 100644 --- a/servers/nextjs/utils/mixpanel.ts +++ b/servers/nextjs/utils/mixpanel.ts @@ -138,16 +138,8 @@ async function ensureTelemetryStatus(): Promise { if (!trackingCheckPromise) { trackingCheckPromise = (async () => { try { - let data; - // Check if running in Electron environment - if (typeof window !== 'undefined' && window.electron?.telemetryStatus) { - // Use Electron IPC handler - data = await window.electron.telemetryStatus(); - } else { - // Fallback to API route for web-based deployments - const res = await fetch('/api/telemetry-status'); - data = await res.json(); - } + const res = await fetch('/api/telemetry-status'); + const data = await res.json(); const enabled = Boolean(data?.telemetryEnabled); window.__mixpanel_telemetry_enabled = enabled; return enabled;