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:
parent
da84c79cb0
commit
3d06644914
31 changed files with 295 additions and 221 deletions
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
BIN
servers/fastapi/static/images/replaceable_template_image.png
Normal file
BIN
servers/fastapi/static/images/replaceable_template_image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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*',
|
||||
},
|
||||
];
|
||||
},
|
||||
|
|
|
|||
22
servers/nextjs/types/global.d.ts
vendored
22
servers/nextjs/types/global.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue