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:
+77
-26
@@ -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">
|
||||
|
||||
+190
@@ -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 }
|
||||
+9
-1
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user