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) ? ( + + ) : ( + + )}