Presentation export: progress modal + inline failure log
Build and Deploy Verso / deploy (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
claude
2026-06-02 09:57:25 +00:00
parent 7e1c2ce53a
commit f2abd42969
4 changed files with 150 additions and 31 deletions
@@ -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": "",
@@ -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<ExportFormat | null>(null)
const [exportError, setExportError] = useState<string | null>(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 (
<Dropdown align="end">
<DropdownToggle
id="download-presentation"
variant="link"
className="pdf-toolbar-btn"
aria-label={t('download')}
<>
<Dropdown align="end">
<DropdownToggle
id="download-presentation"
variant="link"
className="pdf-toolbar-btn"
aria-label={t('download')}
>
<MaterialIcon type="download" />
</DropdownToggle>
<DropdownMenu>
<li role="none">
<DropdownItem
as="button"
onClick={() => startExport('html')}
disabled={exporting !== null}
>
{t('download_as_standalone_html')}
</DropdownItem>
</li>
<li role="none">
<DropdownItem
as="button"
onClick={() => startExport('pdf')}
disabled={exporting !== null}
>
{t('download_as_pdf_slides')}
</DropdownItem>
</li>
</DropdownMenu>
</Dropdown>
<OLModal
show={exporting !== null || exportError !== null}
onHide={dismiss}
>
<MaterialIcon type="download" />
</DropdownToggle>
<DropdownMenu>
<li role="none">
<DropdownItem
href={`/project/${projectId}/presentation-export/html`}
target="_blank"
rel="noopener"
download
>
{t('download_as_standalone_html')}
</DropdownItem>
</li>
<li role="none">
<DropdownItem
href={`/project/${projectId}/presentation-export/pdf`}
target="_blank"
rel="noopener"
download
>
{t('download_as_pdf_slides')}
</DropdownItem>
</li>
</DropdownMenu>
</Dropdown>
<OLModalHeader closeButton>
<OLModalTitle>
{exportError
? t('presentation_export_failed')
: t('preparing_your_download')}
</OLModalTitle>
</OLModalHeader>
<OLModalBody>
{exporting !== null ? (
<LoadingSpinner
loadingText={t('presentation_export_can_take_a_moment')}
/>
) : (
<pre
style={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
maxHeight: '50vh',
overflow: 'auto',
marginBottom: 0,
}}
>
{exportError}
</pre>
)}
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={dismiss}>
{t('close')}
</OLButton>
</OLModalFooter>
</OLModal>
</>
)
}
+3
View File
@@ -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",
+3
View File
@@ -348,6 +348,9 @@
"dont_have_account": "Vous navez 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",