From f2abd42969c28197827fb6fdfb79926ea2eb5bfb Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 2 Jun 2026 09:57:25 +0000 Subject: [PATCH] Presentation export: progress modal + inline failure log The HTML/PDF export links were plain downloads that left the browser silently spinning during the server-side render and, on failure, saved an error page as pdf.txt/pdf.htm. Replace them with fetch-based downloads that show a modal: a spinner with a 'this can take up to a minute' message while compiling, and the actual compile log inline if the export fails. The user can dismiss at any time; a stale request that finishes after dismissal no longer reopens the modal. Adds the three i18n keys (en/fr + extracted). Co-Authored-By: Claude Opus 4.8 --- .../web/frontend/extracted-translations.json | 3 + .../components/pdf-hybrid-download-button.tsx | 172 ++++++++++++++---- services/web/locales/en.json | 3 + services/web/locales/fr.json | 3 + 4 files changed, 150 insertions(+), 31 deletions(-) diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 140a0c79fe..b7fbe1c833 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -1470,6 +1470,9 @@ "premium_feature": "", "premium_plan_label": "", "preparing_for_export": "", + "preparing_your_download": "", + "presentation_export_can_take_a_moment": "", + "presentation_export_failed": "", "presentation_link_members": "", "presentation_link_private": "", "presentation_link_public": "", diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-download-button.tsx b/services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-download-button.tsx index 1019b93e8c..0c9eb21a40 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-download-button.tsx +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-download-button.tsx @@ -1,3 +1,4 @@ +import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context' import { useProjectContext } from '@/shared/context/project-context' @@ -12,6 +13,21 @@ import { DropdownMenu, DropdownToggle, } from '@/shared/components/dropdown/dropdown-menu' +import { + OLModal, + OLModalBody, + OLModalFooter, + OLModalHeader, + OLModalTitle, +} from '@/shared/components/ol/ol-modal' +import LoadingSpinner from '@/shared/components/loading-spinner' + +type ExportFormat = 'html' | 'pdf' + +function filenameFromDisposition(disposition: string | null, ext: string) { + const match = disposition?.match(/filename="?([^"]+)"?/) + return match ? match[1] : `presentation.${ext}` +} function PdfHybridDownloadButton() { const { pdfDownloadUrl, pdfFile, showLogs } = useCompileContext() @@ -19,6 +35,63 @@ function PdfHybridDownloadButton() { const { projectId } = useProjectContext() const { t } = useTranslation() + // Standalone-HTML / slide-PDF exports trigger an on-demand server-side + // render that can take several seconds; surface that wait (and any failure + // log) in a modal instead of leaving the browser silently spinning. + const [exporting, setExporting] = useState(null) + const [exportError, setExportError] = useState(null) + // Bumped whenever the user dismisses the modal, so a request that finishes + // after they've closed it doesn't pop the modal back open. + const requestIdRef = useRef(0) + + const dismiss = useCallback(() => { + requestIdRef.current += 1 + setExporting(null) + setExportError(null) + }, []) + + const startExport = useCallback( + async (format: ExportFormat) => { + const requestId = ++requestIdRef.current + setExportError(null) + setExporting(format) + try { + const response = await fetch( + `/project/${projectId}/presentation-export/${format}`, + { credentials: 'same-origin' } + ) + if (requestId !== requestIdRef.current) return // dismissed meanwhile + if (!response.ok) { + const text = await response.text() + if (requestId !== requestIdRef.current) return + setExporting(null) + setExportError(text || `Export failed (HTTP ${response.status})`) + return + } + const blob = await response.blob() + if (requestId !== requestIdRef.current) return + const filename = filenameFromDisposition( + response.headers.get('Content-Disposition'), + format === 'pdf' ? 'pdf' : 'html' + ) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = filename + document.body.appendChild(link) + link.click() + link.remove() + URL.revokeObjectURL(url) + setExporting(null) + } catch (err) { + if (requestId !== requestIdRef.current) return + setExporting(null) + setExportError(err instanceof Error ? err.message : String(err)) + } + }, + [projectId] + ) + if (showLogs) { return null } @@ -30,38 +103,75 @@ function PdfHybridDownloadButton() { if (isPresentation) { return ( - - + + + + + +
  • + startExport('html')} + disabled={exporting !== null} + > + {t('download_as_standalone_html')} + +
  • +
  • + startExport('pdf')} + disabled={exporting !== null} + > + {t('download_as_pdf_slides')} + +
  • +
    +
    + + - -
    - -
  • - - {t('download_as_standalone_html')} - -
  • -
  • - - {t('download_as_pdf_slides')} - -
  • -
    -
    + + + {exportError + ? t('presentation_export_failed') + : t('preparing_your_download')} + + + + {exporting !== null ? ( + + ) : ( +
    +                {exportError}
    +              
    + )} +
    + + + {t('close')} + + + + ) } diff --git a/services/web/locales/en.json b/services/web/locales/en.json index d33f512f89..0d77f8d713 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -692,6 +692,9 @@ "download_all": "Download all", "download_as_pdf": "Download as PDF", "download_as_pdf_slides": "Download PDF slides", + "preparing_your_download": "Preparing your download", + "presentation_export_can_take_a_moment": "Compiling your presentation. This can take up to a minute for large decks…", + "presentation_export_failed": "Presentation export failed", "download_as_source_zip": "Download as source (.zip)", "download_as_standalone_html": "Download standalone HTML", "download_csv": "Download CSV", diff --git a/services/web/locales/fr.json b/services/web/locales/fr.json index 31f8f5ef52..ca386dd00a 100644 --- a/services/web/locales/fr.json +++ b/services/web/locales/fr.json @@ -348,6 +348,9 @@ "dont_have_account": "Vous n’avez pas de compte ?", "download": "Télécharger", "download_as_pdf_slides": "Télécharger les diapositives PDF", + "preparing_your_download": "Préparation de votre téléchargement", + "presentation_export_can_take_a_moment": "Compilation de votre présentation. Cela peut prendre jusqu'à une minute pour les présentations volumineuses…", + "presentation_export_failed": "Échec de l'export de la présentation", "download_as_standalone_html": "Télécharger le HTML autonome", "download_pdf": "Télécharger le PDF", "download_zip_file": "Télécharger le fichier Zip",