diff --git a/plugin/src/client.ts b/plugin/src/client.ts index cfd9c30..2738d2c 100644 --- a/plugin/src/client.ts +++ b/plugin/src/client.ts @@ -268,6 +268,32 @@ export const deleteTemplate = async (templateUuid: string): Promise => { } } +export const exportPipelineResultAsDocx = async ( + runId: string, + resultMarkdown: string, +): Promise => { + const url = `${getApiBaseUrl()}/pipelines/status/${runId}/export/docx` + const response = await apiFetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + resultMarkdown, + }), + }) + + if (!response.ok) { + // Errors still come back as JSON; only the success path is binary. + const data = await readApiResponse<{ detail?: string }>(response, url).catch( + (): { detail?: string } => ({}), + ) + throw new Error(data.detail || 'Failed to export the result as a Word document.') + } + + return response.blob() +} + export const saveEditedPipelineResult = async ( runId: string, resultMarkdown: string, diff --git a/plugin/src/components/DownloadDropdown.module.css b/plugin/src/components/DownloadDropdown.module.css new file mode 100644 index 0000000..8f573d1 --- /dev/null +++ b/plugin/src/components/DownloadDropdown.module.css @@ -0,0 +1,54 @@ +.root { + position: relative; +} + +.caret { + margin-left: 0.5rem; + font-size: 0.6rem; + vertical-align: middle; +} + +.menu { + position: absolute; + top: calc(100% + 0.35rem); + right: 0; + z-index: 20; + min-width: 11rem; + display: grid; + overflow: hidden; + border: 1px solid var(--ai-doc-color-slate-200); + border-radius: var(--bs-border-radius); + background: var(--ai-doc-color-white); + box-shadow: + 0 10px 30px rgba(15, 23, 42, 0.08), + 0 2px 8px rgba(15, 23, 42, 0.05); +} + +.item { + display: flex; + align-items: center; + gap: 0.6rem; + width: 100%; + padding: 0.6rem 0.9rem; + border: 0; + background: transparent; + color: var(--ai-doc-color-slate-900); + text-align: left; + line-height: 1.35; + transition: background-color 120ms ease; +} + +.item:hover:not(:disabled) { + background: var(--ai-doc-color-blue-50); +} + +.item:disabled { + opacity: 0.65; + cursor: not-allowed; +} + +.itemIcon { + width: 1rem; + font-size: 0.85rem; + color: var(--ai-doc-color-slate-500); +} diff --git a/plugin/src/components/DownloadDropdown.tsx b/plugin/src/components/DownloadDropdown.tsx new file mode 100644 index 0000000..1d48370 --- /dev/null +++ b/plugin/src/components/DownloadDropdown.tsx @@ -0,0 +1,143 @@ +import { useEffect, useRef, useState } from 'react' +import { toast } from 'sonner' + +import { exportPipelineResultAsDocx } from '@/client' +import styles from '@/components/DownloadDropdown.module.css' + +const triggerBlobDownload = (blob: Blob, fileName: string) => { + 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) +} + +type DownloadDropdownProps = { + markdown: string + runId: string | null + baseName: string +} + +/** + * Download menu for a pipeline result, offering the raw markdown or a Word export. + * + * The markdown passed in is the editor's current text, so unsaved edits are downloaded too. + */ +export function DownloadDropdown({ markdown, runId, baseName }: DownloadDropdownProps) { + const [isOpen, setIsOpen] = useState(false) + const [isDownloadingDocx, setIsDownloadingDocx] = useState(false) + const rootRef = useRef(null) + + const fileBaseName = baseName || 'pipeline-output' + + useEffect(() => { + if (!isOpen) { + return + } + + const handlePointerDown = (event: MouseEvent) => { + if (!rootRef.current?.contains(event.target as Node)) { + setIsOpen(false) + } + } + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsOpen(false) + } + } + + document.addEventListener('mousedown', handlePointerDown) + document.addEventListener('keydown', handleEscape) + + return () => { + document.removeEventListener('mousedown', handlePointerDown) + document.removeEventListener('keydown', handleEscape) + } + }, [isOpen]) + + const onDownloadMarkdown = () => { + setIsOpen(false) + + if (!markdown) { + return + } + + const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' }) + triggerBlobDownload(blob, `${fileBaseName}.md`) + toast.success('Markdown download has started.') + } + + const onDownloadDocx = async () => { + setIsOpen(false) + + if (!markdown || !runId) { + toast.error('There is no pipeline result to export yet.') + return + } + + setIsDownloadingDocx(true) + try { + const blob = await exportPipelineResultAsDocx(runId, markdown) + triggerBlobDownload(blob, `${fileBaseName}.docx`) + toast.success('Word download has started.') + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : 'Failed to export the result as a Word document.', + ) + } finally { + setIsDownloadingDocx(false) + } + } + + return ( +
+ + + {isOpen ? ( +
+