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 <noreply@anthropic.com>
This commit is contained in:
claude
2026-06-16 08:48:03 +00:00
parent 703f4d6ee2
commit a6ea291ea8
3 changed files with 276 additions and 27 deletions
@@ -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) {
</li>
)}
</DownloadProjectButton>
<CompileAndDownloadProjectPDFButton project={project}>
{(text, pendingCompile, downloadProject) => (
<li role="none">
<DropdownItem
as="button"
tabIndex={-1}
onClick={e => {
e.stopPropagation()
downloadProject()
}}
leadingIcon={
pendingCompile ? (
<OLSpinner
size="sm"
className="dropdown-item-leading-icon spinner"
/>
) : (
'picture_as_pdf'
)
}
>
{text}
</DropdownItem>
</li>
)}
</CompileAndDownloadProjectPDFButton>
{project.compiler === 'quarto' &&
project.quartoFlavor === 'revealjs' ? (
<DownloadPresentationButton project={project}>
{(startExport, exporting) => (
<>
<li role="none">
<DropdownItem
as="button"
tabIndex={-1}
onClick={() => startExport('html')}
disabled={exporting !== null}
leadingIcon={
exporting === 'html' ? (
<OLSpinner
size="sm"
className="dropdown-item-leading-icon spinner"
/>
) : (
'download'
)
}
>
{t('download_as_standalone_html')}
</DropdownItem>
</li>
<li role="none">
<DropdownItem
as="button"
tabIndex={-1}
onClick={() => startExport('pdf')}
disabled={exporting !== null}
leadingIcon={
exporting === 'pdf' ? (
<OLSpinner
size="sm"
className="dropdown-item-leading-icon spinner"
/>
) : (
'picture_as_pdf'
)
}
>
{t('download_as_pdf_slides')}
</DropdownItem>
</li>
</>
)}
</DownloadPresentationButton>
) : (
<CompileAndDownloadProjectPDFButton project={project}>
{(text, pendingCompile, downloadProject) => (
<li role="none">
<DropdownItem
as="button"
tabIndex={-1}
onClick={e => {
e.stopPropagation()
downloadProject()
}}
leadingIcon={
pendingCompile ? (
<OLSpinner
size="sm"
className="dropdown-item-leading-icon spinner"
/>
) : (
'picture_as_pdf'
)
}
>
{text}
</DropdownItem>
</li>
)}
</CompileAndDownloadProjectPDFButton>
)}
<ArchiveProjectButton project={project}>
{(text, handleOpenModal) => (
<li role="none">
@@ -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<ExportFormat | null>(null)
const [exportError, setExportError] = useState<string | null>(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)}
<OLModal
show={exporting !== null || exportError !== null}
onHide={dismiss}
>
<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>
</>
)
}
// 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 (
<DownloadPresentationButton project={project}>
{(startExport, exporting) => (
<Dropdown drop="up">
<DropdownToggle
id={`download-presentation-toggle-${project.id}`}
variant="link"
className="action-btn"
aria-label={t('download')}
>
{exporting !== null ? (
<OLSpinner size="sm" />
) : (
<MaterialIcon type="download" />
)}
</DropdownToggle>
<DropdownMenu>
<li role="none">
<DropdownItem
as="button"
onClick={() => startExport('html')}
disabled={exporting !== null}
leadingIcon="download"
>
{t('download_as_standalone_html')}
</DropdownItem>
</li>
<li role="none">
<DropdownItem
as="button"
onClick={() => startExport('pdf')}
disabled={exporting !== null}
leadingIcon="picture_as_pdf"
>
{t('download_as_pdf_slides')}
</DropdownItem>
</li>
</DropdownMenu>
</Dropdown>
)}
</DownloadPresentationButton>
)
}
)
export default memo(DownloadPresentationButton)
export { DownloadPresentationButtonTooltip }
@@ -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 (
<>
<CopyProjectButtonTooltip project={project} />
<DownloadProjectButtonTooltip project={project} />
<CompileAndDownloadProjectPDFButtonTooltip project={project} />
{isQuartoSlides(project) ? (
<DownloadPresentationButtonTooltip project={project} />
) : (
<CompileAndDownloadProjectPDFButtonTooltip project={project} />
)}
<ArchiveProjectButtonTooltip project={project} />
<TrashProjectButtonTooltip project={project} />
<UnarchiveProjectButtonTooltip project={project} />