From a6ea291ea89a1f43fc33cafb0156df2c49b1074f Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 16 Jun 2026 08:48:03 +0000 Subject: [PATCH] Add PDF/HTML download dropdown for Quarto Slides in project list Quarto Slides (RevealJS) projects compile to output.html, not output.pdf, so the existing "Download PDF" button was meaningless for them. Replace it with a two-option dropdown matching the editor's PdfHybridDownloadButton: - Desktop (ActionsCell): icon button opens a dropup with "Download standalone HTML" and "Download PDF slides" - Mobile (ActionsDropdown): two separate dropdown items with the same choices and per-format spinner while the export is in progress Both use the same /project/:id/presentation-export/:format endpoint and show a loading modal (with error reporting) during the server-side render, exactly as the editor toolbar does. Non-RevealJS projects continue to show the compile-and-download-PDF button unchanged. Co-Authored-By: Claude Sonnet 4.6 --- .../components/dropdown/actions-dropdown.tsx | 103 +++++++--- .../download-presentation-button.tsx | 190 ++++++++++++++++++ .../components/table/cells/actions-cell.tsx | 10 +- 3 files changed, 276 insertions(+), 27 deletions(-) create mode 100644 services/web/frontend/js/features/project-list/components/table/cells/action-buttons/download-presentation-button.tsx diff --git a/services/web/frontend/js/features/project-list/components/dropdown/actions-dropdown.tsx b/services/web/frontend/js/features/project-list/components/dropdown/actions-dropdown.tsx index c6f13e8e2c..386dfbcb4e 100644 --- a/services/web/frontend/js/features/project-list/components/dropdown/actions-dropdown.tsx +++ b/services/web/frontend/js/features/project-list/components/dropdown/actions-dropdown.tsx @@ -15,6 +15,7 @@ import LeaveProjectButton from '../table/cells/action-buttons/leave-project-butt import DeleteProjectButton from '../table/cells/action-buttons/delete-project-button' import { Project } from '../../../../../../types/project/dashboard/api' import CompileAndDownloadProjectPDFButton from '../table/cells/action-buttons/compile-and-download-project-pdf-button' +import DownloadPresentationButton from '../table/cells/action-buttons/download-presentation-button' import RenameProjectButton from '../table/cells/action-buttons/rename-project-button' import MaterialIcon from '@/shared/components/material-icon' import OLSpinner from '@/shared/components/ol/ol-spinner' @@ -77,32 +78,82 @@ function ActionsDropdown({ project }: ActionDropdownProps) { )} - - {(text, pendingCompile, downloadProject) => ( -
  • - { - e.stopPropagation() - downloadProject() - }} - leadingIcon={ - pendingCompile ? ( - - ) : ( - 'picture_as_pdf' - ) - } - > - {text} - -
  • - )} -
    + {project.compiler === 'quarto' && + project.quartoFlavor === 'revealjs' ? ( + + {(startExport, exporting) => ( + <> +
  • + startExport('html')} + disabled={exporting !== null} + leadingIcon={ + exporting === 'html' ? ( + + ) : ( + 'download' + ) + } + > + {t('download_as_standalone_html')} + +
  • +
  • + startExport('pdf')} + disabled={exporting !== null} + leadingIcon={ + exporting === 'pdf' ? ( + + ) : ( + 'picture_as_pdf' + ) + } + > + {t('download_as_pdf_slides')} + +
  • + + )} +
    + ) : ( + + {(text, pendingCompile, downloadProject) => ( +
  • + { + e.stopPropagation() + downloadProject() + }} + leadingIcon={ + pendingCompile ? ( + + ) : ( + 'picture_as_pdf' + ) + } + > + {text} + +
  • + )} +
    + )} {(text, handleOpenModal) => (
  • diff --git a/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/download-presentation-button.tsx b/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/download-presentation-button.tsx new file mode 100644 index 0000000000..68ff87d241 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/download-presentation-button.tsx @@ -0,0 +1,190 @@ +import { memo, useCallback, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Project } from '../../../../../../../../types/project/dashboard/api' +import { + Dropdown, + DropdownItem, + DropdownMenu, + DropdownToggle, +} from '@/shared/components/dropdown/dropdown-menu' +import { + OLModal, + OLModalBody, + OLModalFooter, + OLModalHeader, + OLModalTitle, +} from '@/shared/components/ol/ol-modal' +import OLButton from '@/shared/components/ol/ol-button' +import OLSpinner from '@/shared/components/ol/ol-spinner' +import LoadingSpinner from '@/shared/components/loading-spinner' +import MaterialIcon from '@/shared/components/material-icon' + +type ExportFormat = 'html' | 'pdf' + +type DownloadPresentationButtonProps = { + project: Project + children: ( + startExport: (format: ExportFormat) => void, + exporting: ExportFormat | null + ) => React.ReactElement +} + +function DownloadPresentationButton({ + project, + children, +}: DownloadPresentationButtonProps) { + const { t } = useTranslation() + const [exporting, setExporting] = useState(null) + const [exportError, setExportError] = useState(null) + 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/${project.id}/presentation-export/${format}`, + { credentials: 'same-origin' } + ) + if (requestId !== requestIdRef.current) return + 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 disposition = response.headers.get('Content-Disposition') + const match = disposition?.match(/filename="?([^"]+)"?/) + const filename = + match ? match[1] : `presentation.${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)) + } + }, + [project.id] + ) + + return ( + <> + {children(startExport, exporting)} + + + + {exportError + ? t('presentation_export_failed') + : t('preparing_your_download')} + + + + {exporting !== null ? ( + + ) : ( +
    +              {exportError}
    +            
    + )} +
    + + + {t('close')} + + +
    + + ) +} + +// For ActionsCell (desktop icon buttons row): an icon button that opens a +// dropdown with PDF and HTML export choices. +const DownloadPresentationButtonTooltip = memo( + function DownloadPresentationButtonTooltip({ + project, + }: { project: Project }) { + const { t } = useTranslation() + + if (project.compiler !== 'quarto' || project.quartoFlavor !== 'revealjs') { + return null + } + + return ( + + {(startExport, exporting) => ( + + + {exporting !== null ? ( + + ) : ( + + )} + + +
  • + startExport('html')} + disabled={exporting !== null} + leadingIcon="download" + > + {t('download_as_standalone_html')} + +
  • +
  • + startExport('pdf')} + disabled={exporting !== null} + leadingIcon="picture_as_pdf" + > + {t('download_as_pdf_slides')} + +
  • + + + )} + + ) + } +) + +export default memo(DownloadPresentationButton) +export { DownloadPresentationButtonTooltip } diff --git a/services/web/frontend/js/features/project-list/components/table/cells/actions-cell.tsx b/services/web/frontend/js/features/project-list/components/table/cells/actions-cell.tsx index 2e0c3f336f..1934684c04 100644 --- a/services/web/frontend/js/features/project-list/components/table/cells/actions-cell.tsx +++ b/services/web/frontend/js/features/project-list/components/table/cells/actions-cell.tsx @@ -8,17 +8,25 @@ import { DownloadProjectButtonTooltip } from './action-buttons/download-project- import { LeaveProjectButtonTooltip } from './action-buttons/leave-project-button' import { DeleteProjectButtonTooltip } from './action-buttons/delete-project-button' import { CompileAndDownloadProjectPDFButtonTooltip } from './action-buttons/compile-and-download-project-pdf-button' +import { DownloadPresentationButtonTooltip } from './action-buttons/download-presentation-button' type ActionsCellProps = { project: Project } +const isQuartoSlides = (project: Project) => + project.compiler === 'quarto' && project.quartoFlavor === 'revealjs' + export default function ActionsCell({ project }: ActionsCellProps) { return ( <> - + {isQuartoSlides(project) ? ( + + ) : ( + + )}