feat: update placeholder image references and improve asset handling

- Replaced all instances of the placeholder image path from "/static/images/placeholder.jpg" to "/static/images/replaceable_template_image.png".
- Added a new Nginx location block for serving app data with a long cache expiration.
- Enhanced the image generation service to return the new template image when generation fails.
- Updated various services and endpoints to ensure consistent handling of asset paths, including resolving backend asset URLs.
- Removed Electron-specific checks from several components to streamline API calls and improve compatibility with web deployments.
- Improved error handling and logging in the PDF export process.
- Adjusted Next.js configuration for API routing to ensure proper asset serving in Docker environments.
This commit is contained in:
sudipnext 2026-04-18 20:56:37 +05:45
parent da84c79cb0
commit 3d06644914
31 changed files with 295 additions and 221 deletions

View file

@ -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";
}
}
}

View file

@ -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)

View file

@ -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)

View file

@ -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:

View file

@ -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

View file

@ -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.

View file

@ -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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View file

@ -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

View file

@ -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}")

View file

@ -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
}

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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);

View file

@ -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)}
>
<TemplatePreviewStage>
<LayoutsBadge count={template.layoutCount} />

View file

@ -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<TemplateSelectionProps> = 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<TemplateSelectionProps> = 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(

View file

@ -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 }) => {
<div className="">
<div
id="presentation-slides-wrapper"
className="mx-auto flex flex-col font-inter items-center overflow-hidden justify-center "
className=" mx-auto flex flex-col items-center overflow-hidden justify-center "
>
{!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
<div key={index} className="w-full" data-speaker-note={slide.speaker_note}>
<V1ContentRender slide={slide} isEditMode={true} theme={null}
<div key={index} className="w-full " data-speaker-note={slide.speaker_note}>
<V1ContentRender
slide={slide}
isEditMode={true}
theme={presentationData?.theme}
/>
</div>
))}

View file

@ -150,20 +150,6 @@ const PresentationHeader = ({
return pptx_model;
};
const exportViaIpc = async (format: "pptx" | "pdf"): Promise<boolean> => {
if (typeof window === 'undefined') return false;
if (!(window as any).electron?.exportPresentation) return false;
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({

View file

@ -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;

View file

@ -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;
}
}

View file

@ -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<T>(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<string, unknown>).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<string, any> = {};
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,

View file

@ -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);

View file

@ -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);

View file

@ -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[] = [];

View file

@ -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*',
},
];
},

View file

@ -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<any>;
exportAsPDF: (id: string, title: string) => Promise<any>;
getUserConfig: () => Promise<any>;
setUserConfig: (userConfig: any) => Promise<any>;
getCanChangeKeys: () => Promise<boolean>;
readFile: (filePath: string) => Promise<{ content: string }>;
getSlideMetadata: (url: string, theme: string, customColors?: any, tempDirectory?: string) => Promise<any>;
getFooter: (userId: string) => Promise<any>;
setFooter: (userId: string, properties: any) => Promise<any>;
getTheme: (userId: string) => Promise<any>;
setTheme: (userId: string, themeData: any) => Promise<any>;
uploadImage: (file: Buffer) => Promise<any>;
writeNextjsLog: (logData: string) => Promise<any>;
clearNextjsLogs: () => Promise<any>;
hasRequiredKey: () => Promise<{ hasKey: boolean }>;
telemetryStatus: () => Promise<{ telemetryEnabled: boolean }>;
getTemplates: () => Promise<Array<{ templateName: string; templateID: string; files: string[]; settings: any }>>;
}
interface Window {
electron?: ElectronAPI;
env?: {
NEXT_PUBLIC_FAST_API: string;
NEXT_PUBLIC_URL: string;

View file

@ -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;
}

View file

@ -138,16 +138,8 @@ async function ensureTelemetryStatus(): Promise<boolean> {
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;